@flighthq/share 0.5.0-next.1944.cd6b6e7 → 0.5.1-edge.638.d4dbd4e
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/README.md +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/share.d.ts +8 -16
- package/dist/share.d.ts.map +1 -1
- package/dist/share.js +27 -158
- package/dist/share.js.map +1 -1
- package/package.json +5 -4
- package/src/share.test.ts +118 -532
- package/src/shareExplicit.test.ts +30 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @flighthq/share
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Explicit host share-sheet commands for content and portable files
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -13,5 +13,5 @@ Import the supported application-facing API from `@flighthq/share`. The `@flight
|
|
|
13
13
|
This package is part of the locked-version Flight SDK graph. Applications may instead install and import `@flighthq/sdk` when package-level tree shaking is sufficient.
|
|
14
14
|
|
|
15
15
|
- [Flight project](https://github.com/flighthq/flight)
|
|
16
|
-
- [Source for @flighthq/share@0.5.
|
|
17
|
-
- [License](https://github.com/flighthq/flight/blob/
|
|
16
|
+
- [Source for @flighthq/share@0.5.1-edge.638.d4dbd4e](https://github.com/flighthq/flight/tree/d4dbd4e347dc8be10b180c85a766ab50333dcb51/packages/share)
|
|
17
|
+
- [License](https://github.com/flighthq/flight/blob/d4dbd4e347dc8be10b180c85a766ab50333dcb51/LICENSE.md)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { attachShareSignals, canShareContent, canShareFiles, detachShareSignals, disposeShareSignals, enableShareSignals, hasShareContentFields, shareContent, shareContentWithResult, shareFiles, shareText, shareUrl, } from './contract';
|
|
2
2
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,aAAa,EACb,kBAAkB,EAClB,mBAAmB,EACnB,kBAAkB,EAClB,qBAAqB,EACrB,YAAY,EACZ,sBAAsB,EACtB,UAAU,EACV,SAAS,EACT,QAAQ,GACT,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { attachShareSignals, canShareContent, canShareFiles, detachShareSignals, disposeShareSignals, enableShareSignals, hasShareContentFields, shareContent, shareContentWithResult, shareFiles, shareText, shareUrl, } from './contract';
|
|
2
2
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,aAAa,EACb,kBAAkB,EAClB,mBAAmB,EACnB,kBAAkB,EAClB,qBAAqB,EACrB,YAAY,EACZ,sBAAsB,EACtB,UAAU,EACV,SAAS,EACT,QAAQ,GACT,MAAM,YAAY,CAAC"}
|
package/dist/share.d.ts
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { ShareBackend, ShareContent, ShareFile, ShareOptions, ShareResult, ShareSignals } from '@flighthq/types/contract';
|
|
1
|
+
import type { HasShareContent, HasShareFiles, ShareContent, ShareFile, ShareResult, ShareSignals } from '@flighthq/types/contract';
|
|
3
2
|
export declare function attachShareSignals(signals: ShareSignals): void;
|
|
4
|
-
export declare function canShareContent(content: Readonly<ShareContent>): boolean;
|
|
5
|
-
export declare function
|
|
3
|
+
export declare function canShareContent(host: HasShareContent, content: Readonly<ShareContent>): boolean;
|
|
4
|
+
export declare function canShareFiles(host: HasShareFiles, files: readonly ShareFile[]): boolean;
|
|
6
5
|
export declare function detachShareSignals(signals: ShareSignals): void;
|
|
7
6
|
export declare function disposeShareSignals(signals: ShareSignals): void;
|
|
8
7
|
export declare function enableShareSignals(): ShareSignals;
|
|
9
|
-
export declare function explainShareBackend(): BackendExplanation;
|
|
10
|
-
export declare function getShareBackend(): ShareBackend;
|
|
11
8
|
export declare function hasShareContentFields(content: Readonly<ShareContent>): boolean;
|
|
12
|
-
export declare function
|
|
13
|
-
export declare function
|
|
14
|
-
export declare function
|
|
15
|
-
export declare function
|
|
16
|
-
export declare function
|
|
17
|
-
export declare function shareContent(content: Readonly<ShareContent>, options?: Readonly<ShareOptions>): Promise<boolean>;
|
|
18
|
-
export declare function shareContentWithResult(content: Readonly<ShareContent>, options?: Readonly<ShareOptions>): Promise<ShareResult>;
|
|
19
|
-
export declare function shareFiles(files: readonly ShareFile[], options?: Readonly<ShareOptions>): Promise<boolean>;
|
|
20
|
-
export declare function shareText(text: string, options?: Readonly<ShareOptions>): Promise<boolean>;
|
|
21
|
-
export declare function shareUrl(url: string, options?: Readonly<ShareOptions>): Promise<boolean>;
|
|
9
|
+
export declare function shareContent(host: HasShareContent, content: Readonly<ShareContent>): Promise<boolean>;
|
|
10
|
+
export declare function shareContentWithResult(host: HasShareContent, content: Readonly<ShareContent>): Promise<ShareResult>;
|
|
11
|
+
export declare function shareFiles(host: HasShareFiles, files: readonly ShareFile[]): Promise<boolean>;
|
|
12
|
+
export declare function shareText(host: HasShareContent, text: string): Promise<boolean>;
|
|
13
|
+
export declare function shareUrl(host: HasShareContent, url: string): Promise<boolean>;
|
|
22
14
|
//# sourceMappingURL=share.d.ts.map
|
package/dist/share.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"share.d.ts","sourceRoot":"","sources":["../src/share.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"share.d.ts","sourceRoot":"","sources":["../src/share.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,eAAe,EACf,aAAa,EACb,YAAY,EACZ,SAAS,EAET,WAAW,EACX,YAAY,EACb,MAAM,0BAA0B,CAAC;AAElC,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAE9D;AAID,wBAAgB,eAAe,CAAC,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,QAAQ,CAAC,YAAY,CAAC,GAAG,OAAO,CAE/F;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,aAAa,EAAE,KAAK,EAAE,SAAS,SAAS,EAAE,GAAG,OAAO,CAGvF;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAE9D;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAG/D;AAED,wBAAgB,kBAAkB,IAAI,YAAY,CAEjD;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,QAAQ,CAAC,YAAY,CAAC,GAAG,OAAO,CAK9E;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,QAAQ,CAAC,YAAY,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAGrG;AAED,wBAAsB,sBAAsB,CAC1C,IAAI,EAAE,eAAe,EACrB,OAAO,EAAE,QAAQ,CAAC,YAAY,CAAC,GAC9B,OAAO,CAAC,WAAW,CAAC,CAStB;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,aAAa,EAAE,KAAK,EAAE,SAAS,SAAS,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAI7F;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE/E;AAED,wBAAgB,QAAQ,CAAC,IAAI,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE7E"}
|
package/dist/share.js
CHANGED
|
@@ -1,82 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createEntity } from '@flighthq/entity/contract';
|
|
2
|
+
import { clearSignal, createSignal, emitSignal } from '@flighthq/signals/contract';
|
|
2
3
|
export function attachShareSignals(signals) {
|
|
3
4
|
_attachedSignals.add(signals);
|
|
4
5
|
}
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
// This is payload validation within the content capability. Capability presence itself is expressed
|
|
7
|
+
// by HasShareContent, so a host without the slot is a type error rather than a false probe.
|
|
8
|
+
export function canShareContent(host, content) {
|
|
9
|
+
return hasShareContentFields(content) && host.share.content.canShareContent(content);
|
|
7
10
|
}
|
|
8
|
-
export function
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return typeof navigator !== 'undefined' && 'share' in navigator;
|
|
12
|
-
},
|
|
13
|
-
canShare(content) {
|
|
14
|
-
if (typeof navigator === 'undefined' || !('share' in navigator))
|
|
15
|
-
return false;
|
|
16
|
-
try {
|
|
17
|
-
const data = shareContentToNavigatorData(content);
|
|
18
|
-
return navigator.canShare?.(data) ?? false;
|
|
19
|
-
}
|
|
20
|
-
catch {
|
|
21
|
-
return false;
|
|
22
|
-
}
|
|
23
|
-
},
|
|
24
|
-
async share(content, _options) {
|
|
25
|
-
if (typeof navigator === 'undefined' || !('share' in navigator) || typeof navigator.share !== 'function') {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
try {
|
|
29
|
-
const data = shareContentToNavigatorData(content);
|
|
30
|
-
await navigator.share(data);
|
|
31
|
-
return true;
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
async shareWithResult(content, _options) {
|
|
38
|
-
if (typeof navigator === 'undefined' || !('share' in navigator) || typeof navigator.share !== 'function') {
|
|
39
|
-
return { completed: false, activityType: null, dismissed: false };
|
|
40
|
-
}
|
|
41
|
-
try {
|
|
42
|
-
const data = shareContentToNavigatorData(content);
|
|
43
|
-
await navigator.share(data);
|
|
44
|
-
return { completed: true, activityType: null, dismissed: false };
|
|
45
|
-
}
|
|
46
|
-
catch (err) {
|
|
47
|
-
const dismissed = err instanceof Error && err.name === 'AbortError';
|
|
48
|
-
return { completed: false, activityType: null, dismissed };
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
};
|
|
11
|
+
export function canShareFiles(host, files) {
|
|
12
|
+
const content = filesContent(files);
|
|
13
|
+
return content !== null && host.share.files.canShareContent(content);
|
|
52
14
|
}
|
|
53
15
|
export function detachShareSignals(signals) {
|
|
54
16
|
_attachedSignals.delete(signals);
|
|
55
17
|
}
|
|
56
18
|
export function disposeShareSignals(signals) {
|
|
57
19
|
detachShareSignals(signals);
|
|
20
|
+
clearSignal(signals.onShareResult);
|
|
58
21
|
}
|
|
59
22
|
export function enableShareSignals() {
|
|
60
|
-
return {
|
|
61
|
-
onShareResult: createSignal(),
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
export function explainShareBackend() {
|
|
65
|
-
if (_custom !== null) {
|
|
66
|
-
return { conflict: _hostConflict, layer: 'custom', operation: null, viability: 'unobserved' };
|
|
67
|
-
}
|
|
68
|
-
if (_host !== null) {
|
|
69
|
-
return {
|
|
70
|
-
conflict: _hostConflict,
|
|
71
|
-
layer: 'host',
|
|
72
|
-
operation: _hostObservation !== null ? _hostObservation.operation : null,
|
|
73
|
-
viability: _hostObservation !== null ? _hostObservation.viability : 'unobserved',
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
return { conflict: false, layer: 'host-not-enabled', operation: null, viability: 'unobserved' };
|
|
77
|
-
}
|
|
78
|
-
export function getShareBackend() {
|
|
79
|
-
return _custom ?? _host ?? _sentinel;
|
|
23
|
+
return createEntity({ onShareResult: createSignal() });
|
|
80
24
|
}
|
|
81
25
|
export function hasShareContentFields(content) {
|
|
82
26
|
if (content.title !== undefined && content.title !== '')
|
|
@@ -85,113 +29,38 @@ export function hasShareContentFields(content) {
|
|
|
85
29
|
return true;
|
|
86
30
|
if (content.url !== undefined && content.url !== '')
|
|
87
31
|
return true;
|
|
88
|
-
if (content.files !== undefined && content.files.length > 0)
|
|
89
|
-
return true;
|
|
90
32
|
return false;
|
|
91
33
|
}
|
|
92
|
-
export function
|
|
93
|
-
if (_host !== null) {
|
|
94
|
-
if (_host !== backend)
|
|
95
|
-
_hostConflict = true;
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
_host = backend;
|
|
99
|
-
}
|
|
100
|
-
export function isShareAvailable() {
|
|
101
|
-
return getShareBackend().isAvailable();
|
|
102
|
-
}
|
|
103
|
-
export function observeShareHostResult(operation, succeeded) {
|
|
104
|
-
_hostObservation = {
|
|
105
|
-
operation,
|
|
106
|
-
viability: succeeded ? 'available' : 'runtime-api-unavailable',
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
export function resetShareBackendForTest() {
|
|
110
|
-
_custom = null;
|
|
111
|
-
_host = null;
|
|
112
|
-
_hostConflict = false;
|
|
113
|
-
_hostObservation = null;
|
|
114
|
-
}
|
|
115
|
-
export function setShareBackend(backend) {
|
|
116
|
-
_custom = backend;
|
|
117
|
-
}
|
|
118
|
-
export function shareContent(content, options) {
|
|
34
|
+
export function shareContent(host, content) {
|
|
119
35
|
if (!hasShareContentFields(content))
|
|
120
36
|
return Promise.resolve(false);
|
|
121
|
-
return
|
|
37
|
+
return host.share.content.shareContent(content);
|
|
122
38
|
}
|
|
123
|
-
export async function shareContentWithResult(
|
|
39
|
+
export async function shareContentWithResult(host, content) {
|
|
124
40
|
if (!hasShareContentFields(content)) {
|
|
125
41
|
return { completed: false, activityType: null, dismissed: false };
|
|
126
42
|
}
|
|
127
|
-
const result = await
|
|
43
|
+
const result = await host.share.content.shareContentWithResult(content);
|
|
128
44
|
for (const signals of _attachedSignals) {
|
|
129
45
|
emitSignal(signals.onShareResult, result);
|
|
130
46
|
}
|
|
131
47
|
return result;
|
|
132
48
|
}
|
|
133
|
-
export function shareFiles(
|
|
134
|
-
|
|
49
|
+
export function shareFiles(host, files) {
|
|
50
|
+
const content = filesContent(files);
|
|
51
|
+
if (content === null)
|
|
52
|
+
return Promise.resolve(false);
|
|
53
|
+
return host.share.files.shareContent(content);
|
|
135
54
|
}
|
|
136
|
-
export function shareText(
|
|
137
|
-
return shareContent({ text }
|
|
55
|
+
export function shareText(host, text) {
|
|
56
|
+
return shareContent(host, { text });
|
|
138
57
|
}
|
|
139
|
-
export function shareUrl(
|
|
140
|
-
return shareContent({ url }
|
|
58
|
+
export function shareUrl(host, url) {
|
|
59
|
+
return shareContent(host, { url });
|
|
141
60
|
}
|
|
142
|
-
let _custom = null;
|
|
143
|
-
let _host = null;
|
|
144
|
-
let _hostConflict = false;
|
|
145
|
-
let _hostObservation = null;
|
|
146
|
-
const _sentinel = {
|
|
147
|
-
canShare() {
|
|
148
|
-
return false;
|
|
149
|
-
},
|
|
150
|
-
isAvailable() {
|
|
151
|
-
return false;
|
|
152
|
-
},
|
|
153
|
-
async share() {
|
|
154
|
-
return false;
|
|
155
|
-
},
|
|
156
|
-
async shareWithResult() {
|
|
157
|
-
return { completed: false, activityType: null, dismissed: false };
|
|
158
|
-
},
|
|
159
|
-
};
|
|
160
61
|
const _attachedSignals = new Set();
|
|
161
|
-
function
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
data.title = content.title;
|
|
165
|
-
if (content.text !== undefined)
|
|
166
|
-
data.text = content.text;
|
|
167
|
-
if (content.url !== undefined)
|
|
168
|
-
data.url = content.url;
|
|
169
|
-
if (content.files !== undefined && content.files.length > 0) {
|
|
170
|
-
data.files = content.files.map((f) => shareFileToDomFile(f));
|
|
171
|
-
}
|
|
172
|
-
return data;
|
|
173
|
-
}
|
|
174
|
-
function shareFileToDomFile(file) {
|
|
175
|
-
const comma = file.dataUrl.indexOf(',');
|
|
176
|
-
if (comma === -1)
|
|
177
|
-
throw new Error('share: dataUrl is not a data URL (no comma)');
|
|
178
|
-
const header = file.dataUrl.substring(0, comma);
|
|
179
|
-
const body = file.dataUrl.substring(comma + 1);
|
|
180
|
-
const isBase64 = header.includes(';base64');
|
|
181
|
-
let bytes;
|
|
182
|
-
if (isBase64) {
|
|
183
|
-
const binary = atob(body);
|
|
184
|
-
bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
185
|
-
for (let i = 0; i < binary.length; i++) {
|
|
186
|
-
bytes[i] = binary.charCodeAt(i);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
else {
|
|
190
|
-
const decoded = decodeURIComponent(body);
|
|
191
|
-
const encoded = new TextEncoder().encode(decoded);
|
|
192
|
-
bytes = new Uint8Array(new ArrayBuffer(encoded.length));
|
|
193
|
-
bytes.set(encoded);
|
|
194
|
-
}
|
|
195
|
-
return new File([bytes], file.name, { type: file.mimeType });
|
|
62
|
+
function filesContent(files) {
|
|
63
|
+
const first = files[0];
|
|
64
|
+
return first === undefined ? null : { files: [first, ...files.slice(1)] };
|
|
196
65
|
}
|
|
197
66
|
//# sourceMappingURL=share.js.map
|
package/dist/share.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"share.js","sourceRoot":"","sources":["../src/share.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"share.js","sourceRoot":"","sources":["../src/share.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flighthq/share",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1-edge.638.d4dbd4e",
|
|
4
4
|
"author": "Joshua Granick and other contributors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,12 +38,13 @@
|
|
|
38
38
|
"clean:dist": "tsx ../../scripts/clean-package-dist.ts"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@flighthq/
|
|
42
|
-
"@flighthq/
|
|
41
|
+
"@flighthq/entity": "0.5.1-edge.638.d4dbd4e",
|
|
42
|
+
"@flighthq/signals": "0.5.1-edge.638.d4dbd4e",
|
|
43
|
+
"@flighthq/types": "0.5.1-edge.638.d4dbd4e"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"typescript": "^5.3.0"
|
|
46
47
|
},
|
|
47
|
-
"description": "
|
|
48
|
+
"description": "Explicit host share-sheet commands for content and portable files",
|
|
48
49
|
"sideEffects": false
|
|
49
50
|
}
|
package/src/share.test.ts
CHANGED
|
@@ -1,609 +1,195 @@
|
|
|
1
|
+
import { createEntity } from '@flighthq/entity/contract';
|
|
1
2
|
import { connectSignal } from '@flighthq/signals/contract';
|
|
2
|
-
import
|
|
3
|
-
|
|
3
|
+
import { EntityRuntimeKey } from '@flighthq/types/contract';
|
|
4
|
+
import type {
|
|
5
|
+
HasShareContent,
|
|
6
|
+
HasShareFiles,
|
|
7
|
+
ShareContent,
|
|
8
|
+
ShareContentBackend,
|
|
9
|
+
ShareFilesBackend,
|
|
10
|
+
ShareResult,
|
|
11
|
+
} from '@flighthq/types/contract';
|
|
12
|
+
|
|
13
|
+
import * as shareContract from './contract';
|
|
4
14
|
import {
|
|
5
15
|
attachShareSignals,
|
|
6
16
|
canShareContent,
|
|
7
|
-
|
|
17
|
+
canShareFiles,
|
|
8
18
|
detachShareSignals,
|
|
9
19
|
disposeShareSignals,
|
|
10
20
|
enableShareSignals,
|
|
11
|
-
getShareBackend,
|
|
12
21
|
hasShareContentFields,
|
|
13
|
-
installShareHostBackend,
|
|
14
|
-
isShareAvailable,
|
|
15
|
-
resetShareBackendForTest,
|
|
16
|
-
setShareBackend,
|
|
17
22
|
shareContent,
|
|
18
23
|
shareContentWithResult,
|
|
19
24
|
shareFiles,
|
|
20
25
|
shareText,
|
|
21
26
|
shareUrl,
|
|
22
|
-
explainShareBackend,
|
|
23
|
-
observeShareHostResult,
|
|
24
27
|
} from './share';
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
type ContentImplementation = Omit<ShareContentBackend, typeof EntityRuntimeKey>;
|
|
30
|
+
type FilesImplementation = Omit<ShareFilesBackend, typeof EntityRuntimeKey>;
|
|
31
|
+
|
|
32
|
+
function contentHost(overrides: Partial<ContentImplementation> = {}): HasShareContent {
|
|
29
33
|
return {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
},
|
|
38
|
-
async shareWithResult(content) {
|
|
39
|
-
(this as { shared: ShareContent | null }).shared = content;
|
|
40
|
-
return { completed: true, activityType: null, dismissed: false };
|
|
41
|
-
},
|
|
42
|
-
canShare() {
|
|
43
|
-
return true;
|
|
34
|
+
share: {
|
|
35
|
+
content: createEntity({
|
|
36
|
+
canShareContent: () => true,
|
|
37
|
+
shareContent: async () => true,
|
|
38
|
+
shareContentWithResult: async () => ({ activityType: null, completed: true, dismissed: false }),
|
|
39
|
+
...overrides,
|
|
40
|
+
} satisfies ContentImplementation),
|
|
44
41
|
},
|
|
45
|
-
...overrides,
|
|
46
42
|
};
|
|
47
43
|
}
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
45
|
+
function filesHost(overrides: Partial<FilesImplementation> = {}): HasShareFiles {
|
|
46
|
+
return {
|
|
47
|
+
share: {
|
|
48
|
+
files: createEntity({
|
|
49
|
+
canShareContent: () => true,
|
|
50
|
+
shareContent: async () => true,
|
|
51
|
+
shareContentWithResult: async () => ({ activityType: null, completed: true, dismissed: false }),
|
|
52
|
+
...overrides,
|
|
53
|
+
} satisfies FilesImplementation),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
61
56
|
}
|
|
62
57
|
|
|
63
|
-
|
|
64
|
-
resetShareBackendForTest();
|
|
65
|
-
restoreNavigator();
|
|
66
|
-
});
|
|
58
|
+
const file = { dataUrl: 'data:text/plain;base64,QQ==', mimeType: 'text/plain', name: 'a.txt' };
|
|
67
59
|
|
|
68
60
|
describe('attachShareSignals', () => {
|
|
69
|
-
it('
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
attachShareSignals(signals); // second attach should not double-fire
|
|
73
|
-
let count = 0;
|
|
74
|
-
connectSignal(signals.onShareResult, () => count++);
|
|
75
|
-
setShareBackend(fakeBackend());
|
|
76
|
-
await shareContentWithResult({ text: 'hi' });
|
|
77
|
-
expect(count).toBe(1);
|
|
78
|
-
detachShareSignals(signals);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('emits onShareResult when shareContentWithResult is called after attach', async () => {
|
|
61
|
+
it('enables delivery from the detailed core command', async () => {
|
|
62
|
+
const result: ShareResult = { activityType: 'mail', completed: true, dismissed: false };
|
|
63
|
+
const host = contentHost({ shareContentWithResult: async () => result });
|
|
82
64
|
const signals = enableShareSignals();
|
|
65
|
+
const listener = vi.fn();
|
|
66
|
+
connectSignal(signals.onShareResult, listener);
|
|
83
67
|
attachShareSignals(signals);
|
|
84
|
-
const results: ShareResult[] = [];
|
|
85
|
-
connectSignal(signals.onShareResult, (r) => results.push(r));
|
|
86
|
-
setShareBackend(fakeBackend());
|
|
87
|
-
await shareContentWithResult({ text: 'hello' });
|
|
88
|
-
expect(results).toHaveLength(1);
|
|
89
|
-
expect(results[0]!.completed).toBe(true);
|
|
90
|
-
detachShareSignals(signals);
|
|
91
|
-
});
|
|
92
68
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
attachShareSignals(signals);
|
|
69
|
+
expect(await shareContentWithResult(host, { title: 'flight' })).toEqual(result);
|
|
70
|
+
expect(listener).toHaveBeenCalledWith(result);
|
|
96
71
|
detachShareSignals(signals);
|
|
97
|
-
let count = 0;
|
|
98
|
-
connectSignal(signals.onShareResult, () => count++);
|
|
99
|
-
setShareBackend(fakeBackend());
|
|
100
|
-
await shareContentWithResult({ text: 'hi' });
|
|
101
|
-
expect(count).toBe(0);
|
|
102
72
|
});
|
|
103
73
|
});
|
|
104
74
|
|
|
105
75
|
describe('canShareContent', () => {
|
|
106
|
-
it('
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
it('returns false from the sentinel when no backend is installed', () => {
|
|
112
|
-
expect(canShareContent({ text: 'x' })).toBe(false);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('returns false when files cannot be shared', () => {
|
|
116
|
-
setShareBackend({
|
|
117
|
-
isAvailable: () => true,
|
|
118
|
-
canShare: () => false,
|
|
119
|
-
share: async () => false,
|
|
120
|
-
shareWithResult: async () => ({ completed: false, activityType: null, dismissed: false }),
|
|
121
|
-
});
|
|
122
|
-
const file: ShareFile = { name: 'img.png', mimeType: 'image/png', dataUrl: 'data:image/png;base64,abc' };
|
|
123
|
-
expect(canShareContent({ files: [file] })).toBe(false);
|
|
124
|
-
});
|
|
76
|
+
it('validates meaningful payloads through the selected content slot', () => {
|
|
77
|
+
const can = vi.fn(() => true);
|
|
78
|
+
const host = contentHost({ canShareContent: can });
|
|
79
|
+
expect(canShareContent(host, { text: 'hello' })).toBe(true);
|
|
80
|
+
expect(can).toHaveBeenCalledWith({ text: 'hello' });
|
|
125
81
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const file: ShareFile = { name: 'img.png', mimeType: 'image/png', dataUrl: 'data:image/png;base64,abc' };
|
|
129
|
-
expect(canShareContent({ files: [file] })).toBe(true);
|
|
82
|
+
// @ts-expect-error an empty object is not a meaningful Share content payload
|
|
83
|
+
expect(canShareContent(contentHost(), {})).toBe(false);
|
|
130
84
|
});
|
|
131
85
|
});
|
|
132
86
|
|
|
133
|
-
describe('
|
|
134
|
-
it('
|
|
135
|
-
const
|
|
136
|
-
expect(
|
|
137
|
-
expect(
|
|
138
|
-
expect(backend.canShare({ text: 'x' })).toBe(false);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('shareWithResult returns completed=false dismissed=false when API is absent', async () => {
|
|
142
|
-
const backend = createWebShareBackend();
|
|
143
|
-
const result = await backend.shareWithResult({ text: 'x' });
|
|
144
|
-
expect(result.completed).toBe(false);
|
|
145
|
-
expect(result.dismissed).toBe(false);
|
|
146
|
-
expect(result.activityType).toBeNull();
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('shareWithResult returns dismissed=true on AbortError', async () => {
|
|
150
|
-
const backend = createWebShareBackend();
|
|
151
|
-
mockNavigator({
|
|
152
|
-
share: async () => {
|
|
153
|
-
const err = new Error('User aborted');
|
|
154
|
-
err.name = 'AbortError';
|
|
155
|
-
throw err;
|
|
156
|
-
},
|
|
157
|
-
canShare: () => true,
|
|
158
|
-
});
|
|
159
|
-
const result = await backend.shareWithResult({ text: 'hi' });
|
|
160
|
-
expect(result.completed).toBe(false);
|
|
161
|
-
expect(result.dismissed).toBe(true);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('shareWithResult returns dismissed=false on non-AbortError', async () => {
|
|
165
|
-
const backend = createWebShareBackend();
|
|
166
|
-
mockNavigator({
|
|
167
|
-
share: async () => {
|
|
168
|
-
const err = new Error('Data error');
|
|
169
|
-
err.name = 'DataError';
|
|
170
|
-
throw err;
|
|
171
|
-
},
|
|
172
|
-
canShare: () => true,
|
|
173
|
-
});
|
|
174
|
-
const result = await backend.shareWithResult({ text: 'hi' });
|
|
175
|
-
expect(result.completed).toBe(false);
|
|
176
|
-
expect(result.dismissed).toBe(false);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('isAvailable returns true when navigator.share exists', () => {
|
|
180
|
-
mockNavigator({ share: async () => {} });
|
|
181
|
-
const backend = createWebShareBackend();
|
|
182
|
-
expect(backend.isAvailable()).toBe(true);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it('canShare delegates files to navigator.canShare', () => {
|
|
186
|
-
let passedData: ShareData | undefined;
|
|
187
|
-
mockNavigator({
|
|
188
|
-
share: async () => {},
|
|
189
|
-
canShare: (data: ShareData) => {
|
|
190
|
-
passedData = data;
|
|
191
|
-
return true;
|
|
192
|
-
},
|
|
193
|
-
});
|
|
194
|
-
const backend = createWebShareBackend();
|
|
195
|
-
const file: ShareFile = { name: 'img.png', mimeType: 'image/png', dataUrl: 'data:image/png;base64,AA==' };
|
|
196
|
-
backend.canShare({ files: [file] });
|
|
197
|
-
expect(passedData?.files).toHaveLength(1);
|
|
198
|
-
expect(passedData?.files?.[0]).toBeInstanceOf(File);
|
|
87
|
+
describe('canShareFiles', () => {
|
|
88
|
+
it('validates through the files slot only for non-empty lists', () => {
|
|
89
|
+
const host = filesHost();
|
|
90
|
+
expect(canShareFiles(host, [file])).toBe(true);
|
|
91
|
+
expect(canShareFiles(host, [])).toBe(false);
|
|
199
92
|
});
|
|
200
93
|
});
|
|
201
94
|
|
|
202
95
|
describe('detachShareSignals', () => {
|
|
203
|
-
it('
|
|
96
|
+
it('stops delivery without clearing the signal Entity', async () => {
|
|
204
97
|
const signals = enableShareSignals();
|
|
205
|
-
|
|
98
|
+
const listener = vi.fn();
|
|
99
|
+
connectSignal(signals.onShareResult, listener);
|
|
100
|
+
attachShareSignals(signals);
|
|
101
|
+
detachShareSignals(signals);
|
|
102
|
+
await shareContentWithResult(contentHost(), { title: 'quiet' });
|
|
103
|
+
expect(listener).not.toHaveBeenCalled();
|
|
206
104
|
});
|
|
207
105
|
});
|
|
208
106
|
|
|
209
107
|
describe('disposeShareSignals', () => {
|
|
210
|
-
it('
|
|
108
|
+
it('dispose detaches and clears listeners', async () => {
|
|
211
109
|
const signals = enableShareSignals();
|
|
110
|
+
const listener = vi.fn();
|
|
111
|
+
connectSignal(signals.onShareResult, listener);
|
|
212
112
|
attachShareSignals(signals);
|
|
213
|
-
|
|
113
|
+
disposeShareSignals(signals);
|
|
114
|
+
await shareContentWithResult(contentHost(), { text: 'quiet' });
|
|
115
|
+
expect(listener).not.toHaveBeenCalled();
|
|
214
116
|
});
|
|
215
117
|
});
|
|
216
118
|
|
|
217
119
|
describe('enableShareSignals', () => {
|
|
218
|
-
it('returns
|
|
219
|
-
|
|
220
|
-
expect(signals.onShareResult).toBeDefined();
|
|
221
|
-
});
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
describe('explainShareBackend', () => {
|
|
225
|
-
afterEach(() => resetShareBackendForTest());
|
|
226
|
-
|
|
227
|
-
it('reports host-not-enabled when no backend is installed', () => {
|
|
228
|
-
resetShareBackendForTest();
|
|
229
|
-
const explanation = explainShareBackend();
|
|
230
|
-
expect(explanation.layer).toBe('host-not-enabled');
|
|
231
|
-
expect(explanation.conflict).toBe(false);
|
|
232
|
-
expect(explanation.viability).toBe('unobserved');
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it('reports custom layer when a custom backend is set', () => {
|
|
236
|
-
setShareBackend(fakeBackend());
|
|
237
|
-
expect(explainShareBackend().layer).toBe('custom');
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it('reports host layer when a host backend is installed', () => {
|
|
241
|
-
installShareHostBackend(fakeBackend());
|
|
242
|
-
expect(explainShareBackend().layer).toBe('host');
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it('reports conflict when two different host backends are installed', () => {
|
|
246
|
-
installShareHostBackend(fakeBackend());
|
|
247
|
-
installShareHostBackend(fakeBackend());
|
|
248
|
-
expect(explainShareBackend().conflict).toBe(true);
|
|
249
|
-
});
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
describe('getShareBackend', () => {
|
|
253
|
-
it('falls back to the sentinel when no backend is installed', () => {
|
|
254
|
-
expect(getShareBackend()).not.toBeNull();
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it('returns the registered backend', () => {
|
|
258
|
-
const backend = fakeBackend();
|
|
259
|
-
setShareBackend(backend);
|
|
260
|
-
expect(getShareBackend()).toBe(backend);
|
|
120
|
+
it('returns an Entity-composed signal group', () => {
|
|
121
|
+
expect(EntityRuntimeKey in enableShareSignals()).toBe(true);
|
|
261
122
|
});
|
|
262
123
|
});
|
|
263
124
|
|
|
264
125
|
describe('hasShareContentFields', () => {
|
|
265
|
-
it('
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it('returns true for non-empty files array', () => {
|
|
290
|
-
const file: ShareFile = { name: 'img.png', mimeType: 'image/png', dataUrl: 'data:image/png;base64,AA==' };
|
|
291
|
-
expect(hasShareContentFields({ files: [file] })).toBe(true);
|
|
292
|
-
});
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
describe('installShareHostBackend', () => {
|
|
296
|
-
afterEach(() => resetShareBackendForTest());
|
|
297
|
-
|
|
298
|
-
it('installs a host backend that getShareBackend returns', () => {
|
|
299
|
-
const backend = fakeBackend();
|
|
300
|
-
installShareHostBackend(backend);
|
|
301
|
-
expect(getShareBackend()).toBe(backend);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('is first-host-wins: a second different backend sets conflict', () => {
|
|
305
|
-
const first = fakeBackend();
|
|
306
|
-
const second = fakeBackend();
|
|
307
|
-
installShareHostBackend(first);
|
|
308
|
-
installShareHostBackend(second);
|
|
309
|
-
expect(getShareBackend()).toBe(first);
|
|
310
|
-
expect(explainShareBackend().conflict).toBe(true);
|
|
311
|
-
});
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
describe('isShareAvailable', () => {
|
|
315
|
-
it('returns false from the sentinel when no backend is installed', () => {
|
|
316
|
-
expect(isShareAvailable()).toBe(false);
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it('returns true when the backend reports available', () => {
|
|
320
|
-
setShareBackend(fakeBackend());
|
|
321
|
-
expect(isShareAvailable()).toBe(true);
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it('returns true when the web backend is installed and navigator.share exists', () => {
|
|
325
|
-
mockNavigator({ share: async () => {} });
|
|
326
|
-
installShareHostBackend(createWebShareBackend());
|
|
327
|
-
expect(isShareAvailable()).toBe(true);
|
|
328
|
-
});
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
describe('observeShareHostResult', () => {
|
|
332
|
-
afterEach(() => resetShareBackendForTest());
|
|
333
|
-
|
|
334
|
-
it('records a successful observation', () => {
|
|
335
|
-
installShareHostBackend(fakeBackend());
|
|
336
|
-
observeShareHostResult('canShare', true);
|
|
337
|
-
const explanation = explainShareBackend();
|
|
338
|
-
expect(explanation.operation).toBe('canShare');
|
|
339
|
-
expect(explanation.viability).toBe('available');
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it('records a failed observation', () => {
|
|
343
|
-
installShareHostBackend(fakeBackend());
|
|
344
|
-
observeShareHostResult('canShare', false);
|
|
345
|
-
expect(explainShareBackend().viability).toBe('runtime-api-unavailable');
|
|
346
|
-
});
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
describe('resetShareBackendForTest', () => {
|
|
350
|
-
it('clears all backend slots', () => {
|
|
351
|
-
setShareBackend(fakeBackend());
|
|
352
|
-
installShareHostBackend(fakeBackend());
|
|
353
|
-
observeShareHostResult('canShare', true);
|
|
354
|
-
resetShareBackendForTest();
|
|
355
|
-
expect(explainShareBackend().layer).toBe('host-not-enabled');
|
|
356
|
-
expect(explainShareBackend().conflict).toBe(false);
|
|
357
|
-
expect(explainShareBackend().viability).toBe('unobserved');
|
|
358
|
-
});
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
describe('setShareBackend', () => {
|
|
362
|
-
it('clears back to the sentinel when passed null', () => {
|
|
363
|
-
setShareBackend(fakeBackend());
|
|
364
|
-
setShareBackend(null);
|
|
365
|
-
expect(getShareBackend()).not.toBeNull();
|
|
126
|
+
it.each<ShareContent>([{ title: 'T' }, { text: 'T' }, { url: 'https://flight.dev' }])(
|
|
127
|
+
'accepts a meaningful content vector',
|
|
128
|
+
(content) => expect(hasShareContentFields(content)).toBe(true),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('Share contract surface', () => {
|
|
133
|
+
it('contains only explicit-host commands, payload validation, and core signal lifecycle', () => {
|
|
134
|
+
expect(Object.keys(shareContract).sort()).toEqual([
|
|
135
|
+
'attachShareSignals',
|
|
136
|
+
'canShareContent',
|
|
137
|
+
'canShareFiles',
|
|
138
|
+
'detachShareSignals',
|
|
139
|
+
'disposeShareSignals',
|
|
140
|
+
'enableShareSignals',
|
|
141
|
+
'hasShareContentFields',
|
|
142
|
+
'shareContent',
|
|
143
|
+
'shareContentWithResult',
|
|
144
|
+
'shareFiles',
|
|
145
|
+
'shareText',
|
|
146
|
+
'shareUrl',
|
|
147
|
+
]);
|
|
366
148
|
});
|
|
367
149
|
});
|
|
368
150
|
|
|
369
151
|
describe('shareContent', () => {
|
|
370
|
-
it('
|
|
371
|
-
const backend =
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
expect(
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
it('returns false from the sentinel when no backend is installed', async () => {
|
|
378
|
-
expect(await shareContent({ text: 'x' })).toBe(false);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
it('returns false immediately for empty content without calling backend', async () => {
|
|
382
|
-
let called = false;
|
|
383
|
-
setShareBackend({
|
|
384
|
-
isAvailable: () => true,
|
|
385
|
-
canShare: () => false,
|
|
386
|
-
share: async () => {
|
|
387
|
-
called = true;
|
|
388
|
-
return true;
|
|
389
|
-
},
|
|
390
|
-
shareWithResult: async () => ({ completed: true, activityType: null, dismissed: false }),
|
|
391
|
-
});
|
|
392
|
-
expect(await shareContent({})).toBe(false);
|
|
393
|
-
expect(called).toBe(false);
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
it('passes options to backend', async () => {
|
|
397
|
-
let passedOptions: ShareOptions | undefined;
|
|
398
|
-
setShareBackend({
|
|
399
|
-
isAvailable: () => true,
|
|
400
|
-
canShare: () => true,
|
|
401
|
-
share: async (_content, options) => {
|
|
402
|
-
passedOptions = options;
|
|
403
|
-
return true;
|
|
404
|
-
},
|
|
405
|
-
shareWithResult: async () => ({ completed: true, activityType: null, dismissed: false }),
|
|
406
|
-
});
|
|
407
|
-
const opts: ShareOptions = { chooserTitle: 'Share via' };
|
|
408
|
-
await shareContent({ text: 'hi' }, opts);
|
|
409
|
-
expect(passedOptions).toEqual(opts);
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
it('shares files', async () => {
|
|
413
|
-
const backend = fakeBackend();
|
|
414
|
-
setShareBackend(backend);
|
|
415
|
-
const file: ShareFile = { name: 'img.png', mimeType: 'image/png', dataUrl: 'data:image/png;base64,AA==' };
|
|
416
|
-
expect(await shareContent({ files: [file] })).toBe(true);
|
|
417
|
-
expect(backend.shared).toEqual({ files: [file] });
|
|
152
|
+
it('rejects declared-but-empty content before dispatch', async () => {
|
|
153
|
+
const backend = vi.fn(async () => true);
|
|
154
|
+
const host = contentHost({ shareContent: backend });
|
|
155
|
+
const empty = { text: '' } as ShareContent;
|
|
156
|
+
expect(canShareContent(host, empty)).toBe(false);
|
|
157
|
+
expect(await shareContent(host, empty)).toBe(false);
|
|
158
|
+
expect(backend).not.toHaveBeenCalled();
|
|
418
159
|
});
|
|
419
160
|
});
|
|
420
161
|
|
|
421
162
|
describe('shareContentWithResult', () => {
|
|
422
|
-
it('returns
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
expect(
|
|
426
|
-
expect(result.dismissed).toBe(false);
|
|
427
|
-
expect(result.activityType).toBeNull();
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
it('returns empty result for empty content without calling backend', async () => {
|
|
431
|
-
let called = false;
|
|
432
|
-
setShareBackend({
|
|
433
|
-
isAvailable: () => true,
|
|
434
|
-
canShare: () => false,
|
|
435
|
-
share: async () => true,
|
|
436
|
-
shareWithResult: async () => {
|
|
437
|
-
called = true;
|
|
438
|
-
return { completed: true, activityType: null, dismissed: false };
|
|
439
|
-
},
|
|
440
|
-
});
|
|
441
|
-
const result = await shareContentWithResult({});
|
|
442
|
-
expect(result.completed).toBe(false);
|
|
443
|
-
expect(called).toBe(false);
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
it('emits onShareResult to attached signal groups', async () => {
|
|
447
|
-
setShareBackend(fakeBackend());
|
|
448
|
-
const signals = enableShareSignals();
|
|
449
|
-
attachShareSignals(signals);
|
|
450
|
-
const results: ShareResult[] = [];
|
|
451
|
-
connectSignal(signals.onShareResult, (r) => results.push(r));
|
|
452
|
-
await shareContentWithResult({ url: 'https://example.com' });
|
|
453
|
-
expect(results).toHaveLength(1);
|
|
454
|
-
expect(results[0]!.completed).toBe(true);
|
|
455
|
-
detachShareSignals(signals);
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
it('does not emit to detached signal groups', async () => {
|
|
459
|
-
setShareBackend(fakeBackend());
|
|
460
|
-
const signals = enableShareSignals();
|
|
461
|
-
attachShareSignals(signals);
|
|
462
|
-
detachShareSignals(signals);
|
|
463
|
-
let count = 0;
|
|
464
|
-
connectSignal(signals.onShareResult, () => count++);
|
|
465
|
-
await shareContentWithResult({ text: 'hi' });
|
|
466
|
-
expect(count).toBe(0);
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
it('passes options to backend', async () => {
|
|
470
|
-
let passedOptions: ShareOptions | undefined;
|
|
471
|
-
setShareBackend({
|
|
472
|
-
isAvailable: () => true,
|
|
473
|
-
canShare: () => true,
|
|
474
|
-
share: async () => true,
|
|
475
|
-
shareWithResult: async (_content, options) => {
|
|
476
|
-
passedOptions = options;
|
|
477
|
-
return { completed: true, activityType: 'com.example.App', dismissed: false };
|
|
478
|
-
},
|
|
479
|
-
});
|
|
480
|
-
const opts: ShareOptions = { excludedActivityTypes: ['com.apple.UIKit.activity.Print'] };
|
|
481
|
-
const result = await shareContentWithResult({ text: 'hi' }, opts);
|
|
482
|
-
expect(passedOptions).toEqual(opts);
|
|
483
|
-
expect(result.activityType).toBe('com.example.App');
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
it('returns false from the sentinel when no backend is installed', async () => {
|
|
487
|
-
const result = await shareContentWithResult({ text: 'x' });
|
|
488
|
-
expect(result.completed).toBe(false);
|
|
163
|
+
it('returns a detailed provider outcome', async () => {
|
|
164
|
+
const result: ShareResult = { activityType: 'mail', completed: true, dismissed: false };
|
|
165
|
+
const host = contentHost({ shareContentWithResult: async () => result });
|
|
166
|
+
expect(await shareContentWithResult(host, { title: 'flight' })).toEqual(result);
|
|
489
167
|
});
|
|
490
168
|
});
|
|
491
169
|
|
|
492
170
|
describe('shareFiles', () => {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
expect(
|
|
499
|
-
expect(
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
it('passes options through', async () => {
|
|
503
|
-
let passedOptions: ShareOptions | undefined;
|
|
504
|
-
setShareBackend({
|
|
505
|
-
isAvailable: () => true,
|
|
506
|
-
canShare: () => true,
|
|
507
|
-
share: async (_content, options) => {
|
|
508
|
-
passedOptions = options;
|
|
509
|
-
return true;
|
|
510
|
-
},
|
|
511
|
-
shareWithResult: async () => ({ completed: true, activityType: null, dismissed: false }),
|
|
512
|
-
});
|
|
513
|
-
const opts: ShareOptions = { chooserTitle: 'Share files' };
|
|
514
|
-
await shareFiles([file], opts);
|
|
515
|
-
expect(passedOptions).toEqual(opts);
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
it('resolves false for an empty array without reaching the backend', async () => {
|
|
519
|
-
const backend = fakeBackend();
|
|
520
|
-
setShareBackend(backend);
|
|
521
|
-
expect(await shareFiles([])).toBe(false);
|
|
522
|
-
expect(backend.shared).toBeNull();
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
it('carries several files in order', async () => {
|
|
526
|
-
const backend = fakeBackend();
|
|
527
|
-
setShareBackend(backend);
|
|
528
|
-
const second: ShareFile = { dataUrl: 'data:text/plain,two', mimeType: 'text/plain', name: 'two.txt' };
|
|
529
|
-
await shareFiles([file, second]);
|
|
530
|
-
expect(backend.shared?.files).toEqual([file, second]);
|
|
171
|
+
it('dispatches a non-empty portable file tuple', async () => {
|
|
172
|
+
const invoke = vi.fn(async (_content: Parameters<ShareFilesBackend['shareContent']>[0]) => true);
|
|
173
|
+
const host = filesHost({ shareContent: invoke });
|
|
174
|
+
expect(await shareFiles(host, [file])).toBe(true);
|
|
175
|
+
expect(await shareFiles(host, [])).toBe(false);
|
|
176
|
+
expect(invoke).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(invoke.mock.calls[0]?.[0].files).toEqual([file]);
|
|
531
178
|
});
|
|
532
179
|
});
|
|
533
180
|
|
|
534
181
|
describe('shareText', () => {
|
|
535
|
-
it('
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
expect(
|
|
539
|
-
expect(backend.shared).toEqual({ text: 'hello world' });
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
it('passes options to shareContent', async () => {
|
|
543
|
-
let passedOptions: ShareOptions | undefined;
|
|
544
|
-
setShareBackend({
|
|
545
|
-
isAvailable: () => true,
|
|
546
|
-
canShare: () => true,
|
|
547
|
-
share: async (_content, options) => {
|
|
548
|
-
passedOptions = options;
|
|
549
|
-
return true;
|
|
550
|
-
},
|
|
551
|
-
shareWithResult: async () => ({ completed: true, activityType: null, dismissed: false }),
|
|
552
|
-
});
|
|
553
|
-
const opts: ShareOptions = { chooserTitle: 'Share text' };
|
|
554
|
-
await shareText('hi', opts);
|
|
555
|
-
expect(passedOptions).toEqual(opts);
|
|
182
|
+
it('delegates text through the content slot', async () => {
|
|
183
|
+
const invoke = vi.fn(async (_content: Parameters<ShareContentBackend['shareContent']>[0]) => true);
|
|
184
|
+
expect(await shareText(contentHost({ shareContent: invoke }), 'hello')).toBe(true);
|
|
185
|
+
expect(invoke).toHaveBeenCalledWith({ text: 'hello' });
|
|
556
186
|
});
|
|
557
187
|
});
|
|
558
188
|
|
|
559
189
|
describe('shareUrl', () => {
|
|
560
|
-
it('
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
expect(
|
|
564
|
-
expect(backend.shared).toEqual({ url: 'https://example.com' });
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
it('passes options to shareContent', async () => {
|
|
568
|
-
let passedOptions: ShareOptions | undefined;
|
|
569
|
-
setShareBackend({
|
|
570
|
-
isAvailable: () => true,
|
|
571
|
-
canShare: () => true,
|
|
572
|
-
share: async (_content, options) => {
|
|
573
|
-
passedOptions = options;
|
|
574
|
-
return true;
|
|
575
|
-
},
|
|
576
|
-
shareWithResult: async () => ({ completed: true, activityType: null, dismissed: false }),
|
|
577
|
-
});
|
|
578
|
-
const opts: ShareOptions = { chooserTitle: 'Share link' };
|
|
579
|
-
await shareUrl('https://example.com', opts);
|
|
580
|
-
expect(passedOptions).toEqual(opts);
|
|
581
|
-
});
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
describe('web backend file conversion', () => {
|
|
585
|
-
it('reports a malformed data URL as unshareable rather than sending the wrong bytes', () => {
|
|
586
|
-
// A dataUrl with no comma used to slip through: indexOf returned -1, so the header read as ''
|
|
587
|
-
// and the body as the whole string, and the converter produced a plausible File containing the
|
|
588
|
-
// URL text. Failing to the ordinary false sentinel is the honest answer.
|
|
589
|
-
mockNavigator({ share: () => Promise.resolve(), canShare: () => true });
|
|
590
|
-
const backend = createWebShareBackend();
|
|
591
|
-
expect(
|
|
592
|
-
backend.canShare({ files: [{ dataUrl: 'data:text/plain;base64', mimeType: 'text/plain', name: 'a.txt' }] }),
|
|
593
|
-
).toBe(false);
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
it('still converts a well-formed data URL', () => {
|
|
597
|
-
mockNavigator({ share: () => Promise.resolve(), canShare: () => true });
|
|
598
|
-
const backend = createWebShareBackend();
|
|
599
|
-
expect(
|
|
600
|
-
backend.canShare({ files: [{ dataUrl: 'data:text/plain;base64,aGk=', mimeType: 'text/plain', name: 'a.txt' }] }),
|
|
601
|
-
).toBe(true);
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
it('rejects a malformed data URL on the share path too, not only the probe', async () => {
|
|
605
|
-
mockNavigator({ share: () => Promise.resolve(), canShare: () => true });
|
|
606
|
-
const backend = createWebShareBackend();
|
|
607
|
-
expect(await backend.share({ files: [{ dataUrl: 'nope', mimeType: 'text/plain', name: 'a.txt' }] })).toBe(false);
|
|
190
|
+
it('delegates a URL through the content slot', async () => {
|
|
191
|
+
const invoke = vi.fn(async (_content: Parameters<ShareContentBackend['shareContent']>[0]) => true);
|
|
192
|
+
expect(await shareUrl(contentHost({ shareContent: invoke }), 'https://flight.dev')).toBe(true);
|
|
193
|
+
expect(invoke).toHaveBeenCalledWith({ url: 'https://flight.dev' });
|
|
608
194
|
});
|
|
609
195
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createEntity } from '@flighthq/entity/contract';
|
|
2
|
+
import type { EntityRuntimeKey, HasShareContent, ShareContentBackend } from '@flighthq/types/contract';
|
|
3
|
+
|
|
4
|
+
import { shareText } from './share';
|
|
5
|
+
|
|
6
|
+
function createRecordingHost(label: string, calls: string[]): HasShareContent {
|
|
7
|
+
const content = createEntity({
|
|
8
|
+
canShareContent: () => true,
|
|
9
|
+
async shareContent(payload) {
|
|
10
|
+
calls.push(`${label}:${payload.text ?? ''}`);
|
|
11
|
+
return true;
|
|
12
|
+
},
|
|
13
|
+
async shareContentWithResult() {
|
|
14
|
+
return { activityType: null, completed: true, dismissed: false };
|
|
15
|
+
},
|
|
16
|
+
} satisfies Omit<ShareContentBackend, typeof EntityRuntimeKey>);
|
|
17
|
+
return { share: { content } };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('explicit Share host isolation', () => {
|
|
21
|
+
it('routes two live hosts to their own content providers', async () => {
|
|
22
|
+
const calls: string[] = [];
|
|
23
|
+
const first = createRecordingHost('first', calls);
|
|
24
|
+
const second = createRecordingHost('second', calls);
|
|
25
|
+
|
|
26
|
+
expect(await shareText(first, 'alpha')).toBe(true);
|
|
27
|
+
expect(await shareText(second, 'beta')).toBe(true);
|
|
28
|
+
expect(calls).toEqual(['first:alpha', 'second:beta']);
|
|
29
|
+
});
|
|
30
|
+
});
|