plusui-native-core 0.1.102 → 0.1.103

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,12 +1,24 @@
1
1
  /**
2
2
  * PlusUI Connection
3
- *
4
- * TWO METHODS. FIVE PRIMITIVES. EVERYTHING YOU NEED.
5
- *
6
- * name.on(callback) - Listen for messages
7
- * name.emit(data) - Send messages
8
- *
9
- * Built-in features (win, clipboard, app) are imported from 'plusui'.
3
+ *
4
+ * SEMANTIC SYNTAX. ZERO CONFIG. ALL 5 PATTERNS.
5
+ *
6
+ * Just write code using `connect.namespace.method()` syntax for CUSTOM
7
+ * frontend ↔ backend communication, then run `plusui connect` to generate bindings.
8
+ *
9
+ * Patterns are auto-detected from your code:
10
+ *
11
+ * connect.user.fetch(123) → Request/Response (CALL)
12
+ * connect.app.onNotify(cb) → Event Listener (SUBSCRIBE)
13
+ * connect.files.startUpload(file) → Fire & Forget (SIMPLEX)
14
+ * connect.settings.onThemeChange(cb) → Event Listener
15
+ *
16
+ * BUILT-IN FEATURES - use direct imports from 'plusui':
17
+ * import plusui from 'plusui';
18
+ * plusui.window.minimize();
19
+ * plusui.clipboard.setText('hello');
20
+ * plusui.app.quit();
21
+ *
10
22
  * Custom channels are auto-generated by `plusui connect`.
11
23
  */
12
24
 
@@ -30,10 +42,12 @@ export type ConnectionEnvelope = {
30
42
  };
31
43
 
32
44
  type MessageCallback = (payload: any) => void;
45
+ type CallHandler = (...args: any[]) => any;
33
46
 
34
47
  class ConnectionClient {
35
48
  private pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }>();
36
49
  private listeners = new Map<string, Set<MessageCallback>>();
50
+ private callHandlers = new Map<string, CallHandler>();
37
51
 
38
52
  constructor() {
39
53
  const host = globalThis as any;
@@ -83,6 +97,7 @@ class ConnectionClient {
83
97
  const env = this.decode(message);
84
98
  if (!env) return;
85
99
 
100
+ // Handle call results
86
101
  if ((env.kind === "result" || env.kind === "error") && env.id) {
87
102
  const entry = this.pending.get(env.id);
88
103
  if (!entry) return;
@@ -95,7 +110,31 @@ class ConnectionClient {
95
110
  return;
96
111
  }
97
112
 
98
- if (env.kind === "event" || env.kind === "stream" || env.kind === "publish") {
113
+ // Handle calls FROM backend (backend calling frontend)
114
+ if (env.kind === "call" && env.id) {
115
+ const handler = this.callHandlers.get(env.name);
116
+ if (handler) {
117
+ try {
118
+ const result = handler(env.payload);
119
+ // If the handler returns a Promise, wait for it
120
+ if (result && typeof result.then === 'function') {
121
+ result.then((resolved: any) => {
122
+ this.send({ kind: "result", id: env.id, name: env.name, payload: resolved });
123
+ }).catch((err: Error) => {
124
+ this.send({ kind: "error", id: env.id, name: env.name, error: err.message });
125
+ });
126
+ } else {
127
+ this.send({ kind: "result", id: env.id!, name: env.name, payload: result });
128
+ }
129
+ } catch (err: any) {
130
+ this.send({ kind: "error", id: env.id!, name: env.name, error: err?.message || "Handler error" });
131
+ }
132
+ }
133
+ return;
134
+ }
135
+
136
+ // Handle events/streams/publishes
137
+ if (env.kind === "event" || env.kind === "stream" || env.kind === "publish" || env.kind === "fire") {
99
138
  const handlers = this.listeners.get(env.name);
100
139
  if (!handlers) return;
101
140
  for (const handler of handlers) {
@@ -104,6 +143,9 @@ class ConnectionClient {
104
143
  }
105
144
  }
106
145
 
146
+ // ---- Public API ----
147
+
148
+ /** Send a call (request/response) to the backend */
107
149
  async call<TOut = unknown, TIn = Record<string, unknown>>(name: string, payload: TIn): Promise<TOut> {
108
150
  const id = this.nextId();
109
151
  const promise = new Promise<TOut>((resolve, reject) => {
@@ -113,10 +155,12 @@ class ConnectionClient {
113
155
  return promise;
114
156
  }
115
157
 
158
+ /** Fire a one-way message (simplex / fire & forget) */
116
159
  fire<TIn = Record<string, unknown>>(name: string, payload: TIn): void {
117
160
  void this.send({ kind: "fire", name, payload });
118
161
  }
119
162
 
163
+ /** Listen for messages on a channel (events, streams, pub-sub) */
120
164
  on<TData = unknown>(name: string, callback: (payload: TData) => void): () => void {
121
165
  const existing = this.listeners.get(name) ?? new Set<MessageCallback>();
122
166
  existing.add(callback as MessageCallback);
@@ -130,30 +174,97 @@ class ConnectionClient {
130
174
  }
131
175
  };
132
176
  }
177
+
178
+ /** Register a handler for backend-initiated calls (backend calling frontend) */
179
+ handle(name: string, handler: CallHandler): () => void {
180
+ this.callHandlers.set(name, handler);
181
+ return () => {
182
+ this.callHandlers.delete(name);
183
+ };
184
+ }
133
185
  }
134
186
 
135
187
  const _client = new ConnectionClient();
136
188
 
189
+ // ============================================================================
190
+ // Semantic Connect Proxy
191
+ // ============================================================================
192
+ // Usage: connect.namespace.method(...args)
193
+ //
194
+ // The proxy automatically routes to the correct wire format:
195
+ // - connect.user.fetch(123) → _client.call('user.fetch', 123)
196
+ // - connect.app.onNotify(cb) → _client.on('app.onNotify', cb)
197
+ // - connect.ui.handlePrompt = fn → _client.handle('ui.handlePrompt', fn)
198
+ // - connect.system.minimize() → _client.fire('system.minimize', {})
199
+ // ============================================================================
200
+
137
201
  /**
138
- * connect - Custom channel API
139
- *
140
- * Use for app-specific communication:
141
- * connect.emit('myEvent', { data: 123 });
142
- * connect.on('myEvent', (data) => { ... });
202
+ * connect Semantic channel proxy
203
+ *
204
+ * Use this for all custom backend ↔ frontend communication.
205
+ * Just write `connect.namespace.method()` and run `plusui connect`.
206
+ *
207
+ * The proxy works even BEFORE running `plusui connect` — it dynamically
208
+ * infers intent from use. Running `plusui connect` generates type
209
+ * declarations for IDE autocomplete and compile-time safety.
143
210
  */
144
- export const connect = {
145
- emit: <TIn = Record<string, unknown>>(name: string, payload: TIn): void => {
146
- _client.fire(name, payload);
147
- },
148
- on: <TData = unknown>(name: string, callback: (payload: TData) => void): (() => void) => {
149
- return _client.on<TData>(name, callback);
211
+ export const connect: Record<string, Record<string, any>> = new Proxy({} as any, {
212
+ get(_target, namespace: string) {
213
+ if (namespace === '__esModule' || namespace === 'then' || typeof namespace === 'symbol') {
214
+ return undefined;
215
+ }
216
+
217
+ // Return a proxy for the namespace that intercepts method calls
218
+ return new Proxy({} as any, {
219
+ get(_nsTarget, method: string) {
220
+ if (typeof method === 'symbol') return undefined;
221
+
222
+ const wireName = `${namespace}.${method}`;
223
+
224
+ // If method starts with 'on' + uppercase → event listener
225
+ if (/^on[A-Z]/.test(method)) {
226
+ return <T = unknown>(cb: (data: T) => void) => _client.on<T>(wireName, cb);
227
+ }
228
+
229
+ // If method starts with 'handle' + uppercase → register a handler for backend calls
230
+ if (/^handle[A-Z]/.test(method)) {
231
+ return (handler: CallHandler) => _client.handle(wireName, handler);
232
+ }
233
+
234
+ // Default: a callable function
235
+ // We return a function that can be either awaited (call) or just invoked (fire).
236
+ // The generated code will be more specific, but this proxy handles the dynamic case.
237
+ return (...args: unknown[]) => {
238
+ const payload = args.length === 1 ? args[0] : args.length === 0 ? {} : args;
239
+ // We use call (request/response) by default so `await` works.
240
+ // If the user doesn't await, the Promise is simply ignored (fire-and-forget).
241
+ return _client.call(wireName, payload);
242
+ };
243
+ },
244
+
245
+ set(_nsTarget, method: string, value: any) {
246
+ if (typeof method === 'symbol') return false;
247
+ const wireName = `${namespace}.${method}`;
248
+
249
+ // Setting a handler: connect.user.handleFetch = (data) => { ... }
250
+ if (typeof value === 'function') {
251
+ _client.handle(wireName, value);
252
+ return true;
253
+ }
254
+ return false;
255
+ },
256
+ });
150
257
  },
151
- feature: createFeatureConnect,
152
- };
258
+ });
259
+
260
+ // ============================================================================
261
+ // Feature-scoped connect (for framework internals)
262
+ // ============================================================================
153
263
 
154
264
  export type FeatureConnect = {
155
265
  on: <TData = unknown>(name: string, callback: (payload: TData) => void) => (() => void);
156
266
  emit: <TIn = Record<string, unknown>>(name: string, payload: TIn) => void;
267
+ call: <TOut = unknown, TIn = Record<string, unknown>>(name: string, payload: TIn) => Promise<TOut>;
157
268
  };
158
269
 
159
270
  function scopeName(scope: string, name: string): string {
@@ -183,6 +294,10 @@ export function createFeatureConnect(scope: string): FeatureConnect {
183
294
  window.removeEventListener(`plusui:${scoped}`, domHandler as EventListener);
184
295
  };
185
296
  },
297
+ call: <TOut = unknown, TIn = Record<string, unknown>>(name: string, payload: TIn): Promise<TOut> => {
298
+ const scoped = scopeName(scope, name);
299
+ return _client.call<TOut, TIn>(scoped, payload);
300
+ },
186
301
  };
187
302
  }
188
303
 
@@ -25,11 +25,21 @@ g.__response__ = function(id: string, result: any, error?: any) {
25
25
  }
26
26
  };
27
27
 
28
+ // Called by C++ when files are dropped on a named zone
28
29
  g.__plusui_fileDrop__ = function(zoneName: string, files: FileInfo[]) {
29
30
  const zone = zoneCallbacks.get(zoneName);
30
31
  if (zone && zone.onFiles) zone.onFiles(files);
31
32
  };
32
33
 
34
+ // Called by C++ when the drop point doesn't hit any named zone — deliver to
35
+ // all registered zones so a single-zone app always works regardless of DPI
36
+ // scaling or hit-test accuracy.
37
+ g.__plusui_fileDrop_default__ = function(files: FileInfo[]) {
38
+ for (const zone of zoneCallbacks.values()) {
39
+ if (zone && zone.onFiles) zone.onFiles(files);
40
+ }
41
+ };
42
+
33
43
  async function invoke<T>(method: string, params: any[] = []): Promise<T> {
34
44
  return new Promise((resolve, reject) => {
35
45
  const id = String(++callId);
@@ -55,32 +65,39 @@ export interface DropZone {
55
65
 
56
66
  export function createDropZone(name: string, el?: HTMLElement | null): DropZone {
57
67
  const element = el || document.querySelector(`[data-dropzone="${name}"]`) as HTMLElement;
58
-
68
+
59
69
  if (element) {
60
70
  element.setAttribute('data-dropzone', name);
61
-
62
- // Prevent browser default on drop (file navigation)
63
- // The C++ backend handles actual file delivery via WM_DROPFILES
71
+
72
+ // Prevent browser default on drop (file navigation).
73
+ // Actual file delivery comes from C++ via WM_DROPFILES → __plusui_fileDrop__.
64
74
  element.addEventListener('drop', (e: DragEvent) => {
65
75
  e.preventDefault();
66
76
  e.stopPropagation();
67
77
  element.classList.remove('dropzone-active');
68
78
  });
69
-
70
- // Visual feedback: the injected C++ script manages dropzone-active
71
- // via document-level listeners, but we also handle cleanup here
79
+
72
80
  element.addEventListener('dragleave', (e: DragEvent) => {
73
- // Only remove if actually leaving the element (not entering a child)
74
81
  const related = e.relatedTarget as Node | null;
75
82
  if (!related || !element.contains(related)) {
76
83
  element.classList.remove('dropzone-active');
77
84
  }
78
85
  });
79
-
86
+
87
+ element.addEventListener('dragenter', (e: DragEvent) => {
88
+ e.preventDefault();
89
+ element.classList.add('dropzone-active');
90
+ if (e.dataTransfer) {
91
+ // 'move' matches the native OS dropzone cursor (arrow + file icon)
92
+ // rather than the browser's '+ Copy' cursor
93
+ try { e.dataTransfer.dropEffect = 'move'; } catch (_) {}
94
+ }
95
+ });
96
+
80
97
  element.addEventListener('dragover', (e: DragEvent) => {
81
98
  e.preventDefault();
82
99
  if (e.dataTransfer) {
83
- try { e.dataTransfer.dropEffect = 'copy'; } catch (_) {}
100
+ try { e.dataTransfer.dropEffect = 'move'; } catch (_) {}
84
101
  }
85
102
  });
86
103
  }