arcane-os 0.94.0 → 0.96.0
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/CHANGELOG.md +36 -0
- package/README.md +1 -1
- package/browser-runtime/ai/core-model-assets.mjs +74 -0
- package/browser-runtime/core/client.mjs +195 -0
- package/docs/reference/README.md +9 -0
- package/docs/reference/ai/browser-speech.md +61 -10
- package/docs/reference/core-client.md +35 -0
- package/docs/reference/core-native-packaging.md +38 -6
- package/docs/reference/inventory/package-api.json +133 -5
- package/docs/reference/inventory/runtime-modules.json +39 -13
- package/docs/reference/model-assets.md +164 -3
- package/docs/reference/native-magpie.md +98 -0
- package/docs/reference/native-nemotron.md +204 -0
- package/docs/reference/native-speech.md +250 -5
- package/docs/reference/runtime-modules.md +157 -11
- package/docs/reference/sdk-api.md +247 -4
- package/package.json +7 -3
- package/runtime/arcane/modules/AI.js +114 -14
- package/runtime/arcane/modules/AIProviderRuntime.js +32 -15
- package/runtime/arcane/modules/ContinuousVoiceCapture.js +118 -21
- package/runtime/arcane/modules/NativeSpeechCapture.js +198 -0
- package/runtime/arcane/modules/NativeSpeechPlayback.js +304 -0
- package/runtime/arcane/modules/VoiceCaptureWorklet.js +62 -2
- package/src/core/host.mjs +1 -0
- package/src/core/services/local-ai.mjs +46 -12
- package/src/core/services/model-assets.mjs +491 -59
- package/src/core/services/speech.mjs +237 -4
- package/src/local-ai/magpie/build.mjs +36 -0
- package/src/local-ai/magpie/helper.mjs +254 -0
- package/src/local-ai/magpie/index.mjs +488 -0
- package/src/local-ai/magpie/native/CMakeLists.txt +10 -0
- package/src/local-ai/magpie/native/main.cpp +270 -0
- package/src/local-ai/nemo-models.mjs +160 -0
- package/src/local-ai/nemotron/build.mjs +53 -0
- package/src/local-ai/nemotron/helper.mjs +308 -0
- package/src/local-ai/nemotron/index.mjs +693 -0
- package/src/local-ai/nemotron/native/CMakeLists.txt +17 -0
- package/src/local-ai/nemotron/native/main.cpp +282 -0
- package/src/native/macos-provider.mjs +14 -7
- package/src/native/portable-assembly.mjs +180 -0
- package/src/native/portable-provider.mjs +17 -161
- package/src/native/publish-output.mjs +101 -0
- package/src/native/windows-provider.mjs +12 -6
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
cmake_minimum_required(VERSION 3.26)
|
|
2
|
+
project(ArcaneNemotron LANGUAGES CXX)
|
|
3
|
+
|
|
4
|
+
find_package(NeMoSpeech REQUIRED COMPONENTS ASR)
|
|
5
|
+
|
|
6
|
+
add_executable(arcane-nemotron main.cpp)
|
|
7
|
+
target_compile_features(arcane-nemotron PRIVATE cxx_std_17)
|
|
8
|
+
target_link_libraries(arcane-nemotron PRIVATE NeMoSpeech::ASR)
|
|
9
|
+
if(MSVC)
|
|
10
|
+
target_compile_options(arcane-nemotron PRIVATE /utf-8 /EHsc)
|
|
11
|
+
endif()
|
|
12
|
+
if(APPLE)
|
|
13
|
+
set_target_properties(arcane-nemotron PROPERTIES INSTALL_RPATH "@loader_path;@loader_path/../lib")
|
|
14
|
+
elseif(UNIX)
|
|
15
|
+
set_target_properties(arcane-nemotron PROPERTIES INSTALL_RPATH "$ORIGIN;$ORIGIN/../lib")
|
|
16
|
+
endif()
|
|
17
|
+
install(TARGETS arcane-nemotron RUNTIME DESTINATION bin)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#include <nemo_speech/asr.h>
|
|
2
|
+
|
|
3
|
+
#include <cstdint>
|
|
4
|
+
#include <cstring>
|
|
5
|
+
#include <iomanip>
|
|
6
|
+
#include <iostream>
|
|
7
|
+
#include <limits>
|
|
8
|
+
#include <locale>
|
|
9
|
+
#include <memory>
|
|
10
|
+
#include <sstream>
|
|
11
|
+
#include <stdexcept>
|
|
12
|
+
#include <string>
|
|
13
|
+
#include <vector>
|
|
14
|
+
|
|
15
|
+
#ifdef _WIN32
|
|
16
|
+
#include <fcntl.h>
|
|
17
|
+
#include <io.h>
|
|
18
|
+
#include <windows.h>
|
|
19
|
+
#endif
|
|
20
|
+
|
|
21
|
+
namespace {
|
|
22
|
+
|
|
23
|
+
using Recognizer = std::unique_ptr<nemo_speech_asr_recognizer, decltype(&nemo_speech_asr_destroy)>;
|
|
24
|
+
using Stream = std::unique_ptr<nemo_speech_asr_stream, decltype(&nemo_speech_asr_stream_close)>;
|
|
25
|
+
using Result = std::unique_ptr<nemo_speech_asr_result, decltype(&nemo_speech_asr_result_destroy)>;
|
|
26
|
+
|
|
27
|
+
void check(nemo_speech_asr_status status) {
|
|
28
|
+
if (status != NEMO_SPEECH_ASR_OK) {
|
|
29
|
+
const char* message = nemo_speech_asr_last_error();
|
|
30
|
+
throw std::runtime_error(message ? message : "The native ASR operation failed.");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
std::string json_string(const char* value) {
|
|
35
|
+
std::ostringstream output;
|
|
36
|
+
output << '"';
|
|
37
|
+
if (value) {
|
|
38
|
+
for (const unsigned char character : std::string(value)) {
|
|
39
|
+
switch (character) {
|
|
40
|
+
case '"': output << "\\\""; break;
|
|
41
|
+
case '\\': output << "\\\\"; break;
|
|
42
|
+
case '\n': output << "\\n"; break;
|
|
43
|
+
case '\r': output << "\\r"; break;
|
|
44
|
+
case '\t': output << "\\t"; break;
|
|
45
|
+
default:
|
|
46
|
+
if (character < 0x20) {
|
|
47
|
+
output << "\\u" << std::hex << std::setw(4) << std::setfill('0')
|
|
48
|
+
<< static_cast<unsigned int>(character) << std::dec;
|
|
49
|
+
} else {
|
|
50
|
+
output << character;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
output << '"';
|
|
56
|
+
return output.str();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
void emit_error(uint64_t request, const std::string& message) {
|
|
60
|
+
std::cout << "{\"request\":" << request << ",\"error\":" << json_string(message.c_str())
|
|
61
|
+
<< "}\n" << std::flush;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
void emit_ack(uint64_t request) {
|
|
65
|
+
std::cout << "{\"request\":" << request << ",\"ok\":true}\n" << std::flush;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
void emit_running(uint64_t request) {
|
|
69
|
+
std::cout << "{\"request\":" << request << ",\"running\":true}\n" << std::flush;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
std::string read_text(size_t count) {
|
|
73
|
+
std::string text(count, '\0');
|
|
74
|
+
if (count && !std::cin.read(text.data(), count)) {
|
|
75
|
+
throw std::runtime_error("Nemotron input ended during a language field.");
|
|
76
|
+
}
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
std::vector<float> read_audio(size_t samples) {
|
|
81
|
+
std::vector<float> audio(samples);
|
|
82
|
+
std::vector<unsigned char> encoded(samples * sizeof(float));
|
|
83
|
+
if (!encoded.empty() && !std::cin.read(reinterpret_cast<char*>(encoded.data()), encoded.size())) {
|
|
84
|
+
throw std::runtime_error("Nemotron input ended during a float32 audio frame.");
|
|
85
|
+
}
|
|
86
|
+
for (size_t index = 0; index < samples; ++index) {
|
|
87
|
+
const auto* value = encoded.data() + index * sizeof(float);
|
|
88
|
+
const uint32_t bits = static_cast<uint32_t>(value[0])
|
|
89
|
+
| (static_cast<uint32_t>(value[1]) << 8)
|
|
90
|
+
| (static_cast<uint32_t>(value[2]) << 16)
|
|
91
|
+
| (static_cast<uint32_t>(value[3]) << 24);
|
|
92
|
+
std::memcpy(&audio[index], &bits, sizeof(float));
|
|
93
|
+
}
|
|
94
|
+
return audio;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
void emit_result(uint64_t request, uint64_t sequence, const nemo_speech_asr_result* result) {
|
|
98
|
+
std::ostringstream output;
|
|
99
|
+
output.imbue(std::locale::classic());
|
|
100
|
+
output << std::setprecision(std::numeric_limits<float>::max_digits10);
|
|
101
|
+
output << "{\"request\":" << request << ",\"update\":{\"sequence\":" << sequence
|
|
102
|
+
<< ",\"final\":" << (nemo_speech_asr_result_is_final(result) ? "true" : "false")
|
|
103
|
+
<< ",\"audioProcessedSeconds\":" << nemo_speech_asr_result_audio_processed(result)
|
|
104
|
+
<< ",\"channelTag\":" << nemo_speech_asr_result_channel_tag(result)
|
|
105
|
+
<< ",\"alternatives\":[";
|
|
106
|
+
const size_t alternatives = nemo_speech_asr_result_alternative_count(result);
|
|
107
|
+
for (size_t alternative = 0; alternative < alternatives; ++alternative) {
|
|
108
|
+
if (alternative) output << ',';
|
|
109
|
+
output << "{\"text\":" << json_string(nemo_speech_asr_result_transcript(result, alternative))
|
|
110
|
+
<< ",\"confidence\":" << nemo_speech_asr_result_confidence(result, alternative)
|
|
111
|
+
<< ",\"words\":[";
|
|
112
|
+
const size_t words = nemo_speech_asr_result_word_count(result, alternative);
|
|
113
|
+
for (size_t index = 0; index < words; ++index) {
|
|
114
|
+
if (index) output << ',';
|
|
115
|
+
output << "{\"word\":" << json_string(nemo_speech_asr_result_word_text(result, alternative, index))
|
|
116
|
+
<< ",\"startTimeMs\":" << nemo_speech_asr_result_word_start_time(result, alternative, index)
|
|
117
|
+
<< ",\"endTimeMs\":" << nemo_speech_asr_result_word_end_time(result, alternative, index)
|
|
118
|
+
<< ",\"confidence\":" << nemo_speech_asr_result_word_confidence(result, alternative, index)
|
|
119
|
+
<< ",\"speaker\":" << nemo_speech_asr_result_word_speaker_tag(result, alternative, index) << '}';
|
|
120
|
+
}
|
|
121
|
+
output << "],\"languageCodes\":[";
|
|
122
|
+
const size_t languages = nemo_speech_asr_result_language_count(result, alternative);
|
|
123
|
+
for (size_t index = 0; index < languages; ++index) {
|
|
124
|
+
if (index) output << ',';
|
|
125
|
+
output << json_string(nemo_speech_asr_result_language_code(result, alternative, index));
|
|
126
|
+
}
|
|
127
|
+
output << "]}";
|
|
128
|
+
}
|
|
129
|
+
output << "]}}\n";
|
|
130
|
+
// A complete result is emitted before asking for the next one. An error
|
|
131
|
+
// later in the drain cannot erase earlier native results.
|
|
132
|
+
std::cout << output.str() << std::flush;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
void drain(uint64_t request, nemo_speech_asr_stream* stream, uint64_t& sequence) {
|
|
136
|
+
while (true) {
|
|
137
|
+
nemo_speech_asr_result* raw = nullptr;
|
|
138
|
+
const auto status = nemo_speech_asr_stream_next(stream, &raw);
|
|
139
|
+
const Result result(raw, nemo_speech_asr_result_destroy);
|
|
140
|
+
check(status);
|
|
141
|
+
if (!result) return;
|
|
142
|
+
emit_result(request, ++sequence, result.get());
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
int serve(const std::string& model_path, const std::string& diarization_path, int gpu) {
|
|
147
|
+
#ifdef _WIN32
|
|
148
|
+
_setmode(_fileno(stdin), _O_BINARY);
|
|
149
|
+
_setmode(_fileno(stdout), _O_BINARY);
|
|
150
|
+
#endif
|
|
151
|
+
nemo_speech_asr_backend_config backend{};
|
|
152
|
+
backend.size = sizeof(backend);
|
|
153
|
+
backend.gpu = gpu;
|
|
154
|
+
nemo_speech_asr_model_config model{};
|
|
155
|
+
model.size = sizeof(model);
|
|
156
|
+
model.path = model_path.c_str();
|
|
157
|
+
nemo_speech_asr_diar_config diarization{};
|
|
158
|
+
diarization.size = sizeof(diarization);
|
|
159
|
+
diarization.model_path = diarization_path.empty() ? nullptr : diarization_path.c_str();
|
|
160
|
+
diarization.left_context_frames = -1;
|
|
161
|
+
nemo_speech_asr_endpointing_config endpointing{};
|
|
162
|
+
endpointing.size = sizeof(endpointing);
|
|
163
|
+
endpointing.enable = false;
|
|
164
|
+
nemo_speech_asr_recognizer_config configuration{};
|
|
165
|
+
configuration.size = sizeof(configuration);
|
|
166
|
+
configuration.backend = &backend;
|
|
167
|
+
configuration.model = &model;
|
|
168
|
+
configuration.diar = &diarization;
|
|
169
|
+
configuration.endpointing = &endpointing;
|
|
170
|
+
nemo_speech_asr_recognizer* raw_recognizer = nullptr;
|
|
171
|
+
const auto status = nemo_speech_asr_create(&configuration, &raw_recognizer);
|
|
172
|
+
const Recognizer recognizer(raw_recognizer, nemo_speech_asr_destroy);
|
|
173
|
+
check(status);
|
|
174
|
+
// The stream is destroyed before its recognizer. All stream calls stay on
|
|
175
|
+
// this thread; the host owns capture, timing and conversational decisions.
|
|
176
|
+
Stream stream(nullptr, nemo_speech_asr_stream_close);
|
|
177
|
+
int sample_rate = 16000;
|
|
178
|
+
uint64_t sequence = 0;
|
|
179
|
+
std::string language;
|
|
180
|
+
std::cout << "{\"ready\":true,\"version\":" << json_string(nemo_speech_asr_version())
|
|
181
|
+
<< ",\"requestedGpu\":" << gpu << "}\n" << std::flush;
|
|
182
|
+
std::string line;
|
|
183
|
+
while (std::getline(std::cin, line)) {
|
|
184
|
+
uint64_t request = 0;
|
|
185
|
+
std::string operation;
|
|
186
|
+
std::istringstream command(line);
|
|
187
|
+
if (!(command >> operation >> request)) {
|
|
188
|
+
emit_error(0, "Unreadable Nemotron command.");
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
if (operation == "open") {
|
|
193
|
+
size_t language_length = 0;
|
|
194
|
+
int requested_rate = 0;
|
|
195
|
+
if (!(command >> requested_rate >> language_length)) {
|
|
196
|
+
throw std::runtime_error("Unreadable Nemotron stream configuration.");
|
|
197
|
+
}
|
|
198
|
+
const std::string requested_language = read_text(language_length);
|
|
199
|
+
if (stream) throw std::runtime_error("A Nemotron stream is already open.");
|
|
200
|
+
language = requested_language;
|
|
201
|
+
sample_rate = requested_rate;
|
|
202
|
+
auto options = nemo_speech_asr_recognition_options_default();
|
|
203
|
+
options.language_code = language.empty() ? nullptr : language.c_str();
|
|
204
|
+
options.interim_results = true;
|
|
205
|
+
options.enable_word_time_offsets = true;
|
|
206
|
+
options.enable_speaker_diarization = !diarization_path.empty();
|
|
207
|
+
options.enable_automatic_punctuation = false;
|
|
208
|
+
options.profanity_filter = false;
|
|
209
|
+
options.verbatim_transcripts = true;
|
|
210
|
+
nemo_speech_asr_stream* raw_stream = nullptr;
|
|
211
|
+
emit_running(request);
|
|
212
|
+
const auto opened = nemo_speech_asr_streaming_recognize(recognizer.get(), &options, &raw_stream);
|
|
213
|
+
stream.reset(raw_stream);
|
|
214
|
+
check(opened);
|
|
215
|
+
sequence = 0;
|
|
216
|
+
} else if (operation == "close") {
|
|
217
|
+
stream.reset();
|
|
218
|
+
} else {
|
|
219
|
+
std::vector<float> audio;
|
|
220
|
+
if (operation == "push") {
|
|
221
|
+
size_t samples = 0;
|
|
222
|
+
if (!(command >> samples)) throw std::runtime_error("Unreadable Nemotron audio frame.");
|
|
223
|
+
audio = read_audio(samples);
|
|
224
|
+
}
|
|
225
|
+
if (!stream) throw std::runtime_error("No Nemotron stream is open.");
|
|
226
|
+
emit_running(request);
|
|
227
|
+
if (operation == "push") {
|
|
228
|
+
check(nemo_speech_asr_stream_push_f32(stream.get(), audio.data(), audio.size(), sample_rate));
|
|
229
|
+
} else if (operation == "endpoint") {
|
|
230
|
+
check(nemo_speech_asr_stream_force_endpoint(stream.get()));
|
|
231
|
+
} else if (operation == "finish") {
|
|
232
|
+
check(nemo_speech_asr_stream_finish(stream.get()));
|
|
233
|
+
} else {
|
|
234
|
+
throw std::runtime_error("Unknown Nemotron operation.");
|
|
235
|
+
}
|
|
236
|
+
drain(request, stream.get(), sequence);
|
|
237
|
+
if (operation == "finish") stream.reset();
|
|
238
|
+
}
|
|
239
|
+
emit_ack(request);
|
|
240
|
+
} catch (const std::exception& error) {
|
|
241
|
+
emit_error(request, error.what());
|
|
242
|
+
if (!std::cin) return 1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
int run(const std::vector<std::string>& arguments) {
|
|
249
|
+
try {
|
|
250
|
+
if (arguments.size() != 4) {
|
|
251
|
+
throw std::runtime_error("Usage: arcane-nemotron ASR.gguf DIARIZATION.gguf GPU_ORDINAL (-1 selects CPU).");
|
|
252
|
+
}
|
|
253
|
+
return serve(arguments[1], arguments[2], std::stoi(arguments[3]));
|
|
254
|
+
} catch (const std::exception& error) {
|
|
255
|
+
emit_error(0, error.what());
|
|
256
|
+
return 1;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
} // namespace
|
|
261
|
+
|
|
262
|
+
#ifdef _WIN32
|
|
263
|
+
int wmain(int argc, wchar_t** argv) {
|
|
264
|
+
std::vector<std::string> arguments;
|
|
265
|
+
for (int index = 0; index < argc; ++index) {
|
|
266
|
+
const int length = WideCharToMultiByte(CP_UTF8, 0, argv[index], -1, nullptr, 0, nullptr, nullptr);
|
|
267
|
+
if (length == 0) {
|
|
268
|
+
emit_error(0, "Windows could not encode a Nemotron argument as UTF-8.");
|
|
269
|
+
return 1;
|
|
270
|
+
}
|
|
271
|
+
std::string argument(length, '\0');
|
|
272
|
+
WideCharToMultiByte(CP_UTF8, 0, argv[index], -1, argument.data(), length, nullptr, nullptr);
|
|
273
|
+
argument.pop_back();
|
|
274
|
+
arguments.push_back(argument);
|
|
275
|
+
}
|
|
276
|
+
return run(arguments);
|
|
277
|
+
}
|
|
278
|
+
#else
|
|
279
|
+
int main(int argc, char** argv) {
|
|
280
|
+
return run(std::vector<std::string>(argv, argv + argc));
|
|
281
|
+
}
|
|
282
|
+
#endif
|
|
@@ -7,6 +7,8 @@ import Is from 'strong-type';
|
|
|
7
7
|
import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
|
|
8
8
|
import {runProcess} from '../process.mjs';
|
|
9
9
|
import {createPortableNativeProvider} from './portable-provider.mjs';
|
|
10
|
+
import {assemblePortable} from './portable-assembly.mjs';
|
|
11
|
+
import {publishNativeOutput} from './publish-output.mjs';
|
|
10
12
|
import {outputFile, writeOutput} from './portable-layout.mjs';
|
|
11
13
|
|
|
12
14
|
const is = new Is(false);
|
|
@@ -224,19 +226,19 @@ export function createMacOSNativeProvider({services, hostDirectory} = {}) {
|
|
|
224
226
|
const target = macosTarget(request.targetRequest);
|
|
225
227
|
const selected = hostDirectory ?? request.toolchain?.hostDirectory;
|
|
226
228
|
const selectedHost = selected === undefined ? null : await requireHostDirectory(selected);
|
|
227
|
-
const artifact = await
|
|
229
|
+
const artifact = await assemblePortable({
|
|
228
230
|
...request, targetRequest: portableRequest(target),
|
|
229
231
|
async onEvent(event) {
|
|
230
|
-
|
|
232
|
+
await emit(onEvent, {...event, target: target.target});
|
|
231
233
|
}
|
|
232
|
-
});
|
|
234
|
+
}, {services, packagedWeb: true, webKitDocumentLifecycle: true});
|
|
233
235
|
const portableRoot = artifact.target.rootDir;
|
|
234
236
|
let assembly;
|
|
235
237
|
try {
|
|
236
238
|
const host = selectedHost ?? await releasedHostDirectory(path.resolve(request.outputRoot), artifact.manifest.sdk.version, target, {signal, onEvent});
|
|
237
239
|
throwIfAborted(signal);
|
|
238
240
|
assembly = await mkdtemp(path.join(path.resolve(request.outputRoot), 'arcane-macos-'));
|
|
239
|
-
const plist = outputFile(assembly,
|
|
241
|
+
const plist = outputFile(assembly, 'Contents/Info.plist');
|
|
240
242
|
const contents = path.dirname(plist);
|
|
241
243
|
const root = path.join(contents, 'Resources');
|
|
242
244
|
const bundleRoot = path.dirname(contents);
|
|
@@ -256,9 +258,14 @@ export function createMacOSNativeProvider({services, hostDirectory} = {}) {
|
|
|
256
258
|
};
|
|
257
259
|
await writeFile(path.join(root, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, {signal});
|
|
258
260
|
throwIfAborted(signal);
|
|
259
|
-
await
|
|
260
|
-
|
|
261
|
-
|
|
261
|
+
const publishedBundle = await publishNativeOutput({
|
|
262
|
+
sourceRoot: bundleRoot, outputRoot: path.dirname(portableRoot), appId: request.appDescriptor.id, bundle: true, signal,
|
|
263
|
+
async onPublished(finalBundle) {
|
|
264
|
+
await emit(onEvent, {type: 'native.payload.completed', target: target.target, appId: request.appDescriptor.id,
|
|
265
|
+
outputRoot: path.join(finalBundle, 'Contents', 'Resources'), bundleRoot: finalBundle});
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
return {...artifact, target: {...target, rootDir: path.join(publishedBundle, 'Contents', 'Resources')}, bundleRoot: publishedBundle, manifest};
|
|
262
269
|
} catch (error) {
|
|
263
270
|
return removeOwnedDirectories(assembly ? [portableRoot, assembly] : [portableRoot], error);
|
|
264
271
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {mkdir, mkdtemp, readFile, realpath, rm} from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import Is from 'strong-type';
|
|
4
|
+
import {createCoreClassicSource} from '../core-classic-source.mjs';
|
|
5
|
+
import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
|
|
6
|
+
import {SDK_ROOT, outputFile, writeOutput, copyRelease, copyCoreRuntime, coreEntrySource} from './portable-layout.mjs';
|
|
7
|
+
|
|
8
|
+
const is = new Is(false);
|
|
9
|
+
const MANIFEST = 'arcane-native.json';
|
|
10
|
+
|
|
11
|
+
async function emit(onEvent, event) {
|
|
12
|
+
if (is.function(onEvent)) await onEvent(event);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function contains(root, candidate) {
|
|
16
|
+
const relative = path.relative(root, candidate);
|
|
17
|
+
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function futureRealPath(location) {
|
|
21
|
+
try { return await realpath(location); }
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code !== 'ENOENT') throw error;
|
|
24
|
+
const parent = path.dirname(location);
|
|
25
|
+
if (parent === location) throw error;
|
|
26
|
+
return path.join(await futureRealPath(parent), path.basename(location));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function outputDirectory(request) {
|
|
31
|
+
if (!is.string(request.outputRoot) || !request.outputRoot) {
|
|
32
|
+
throw new ArcaneError(ERROR_CODES.usage, 'Portable assembly requires an outputRoot.');
|
|
33
|
+
}
|
|
34
|
+
const output = await futureRealPath(path.resolve(request.outputRoot));
|
|
35
|
+
const protectedRoots = [SDK_ROOT, request.appReleaseRoot,
|
|
36
|
+
...(request.dependencies?.map(function dependencyRoot(item) { return item.releaseRoot; }) ?? []),
|
|
37
|
+
...(request.protectedRoots ?? [])];
|
|
38
|
+
for (const location of protectedRoots) {
|
|
39
|
+
if (!location) continue;
|
|
40
|
+
const root = await futureRealPath(path.resolve(location));
|
|
41
|
+
// SDK/release roots must never be replaced by generated output. A
|
|
42
|
+
// caller may select its ordinary project-local dist descendant.
|
|
43
|
+
if (contains(output, root) || (location !== SDK_ROOT && contains(root, output))) {
|
|
44
|
+
throw new ArcaneError(ERROR_CODES.usage, `Portable output overlaps selected source: ${location}.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
await mkdir(output, {recursive: true});
|
|
48
|
+
return output;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function serviceSelection(services, release) {
|
|
52
|
+
return services.map(function selectService(service) {
|
|
53
|
+
if (!is.string(service?.module) || !release.files.includes(service.module)) {
|
|
54
|
+
throw new ArcaneError(ERROR_CODES.prerequisiteMissing, `The selected release does not contain service module ${String(service?.module)}.`);
|
|
55
|
+
}
|
|
56
|
+
return {...service, options: service.options === undefined ? {} : service.options};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function eventOwnerPath(release) {
|
|
61
|
+
const paths = release.files.filter(function sdkEventModule(relative) {
|
|
62
|
+
return relative === 'sdk/event-manager.mjs' || relative.endsWith('/sdk/event-manager.mjs')
|
|
63
|
+
|| /^node_modules\/(?:@[^/]+\/)?[^/]+\/browser-runtime\/event-manager\.mjs$/u.test(relative);
|
|
64
|
+
});
|
|
65
|
+
if (paths.length !== 1) {
|
|
66
|
+
throw new ArcaneError(ERROR_CODES.prerequisiteMissing,
|
|
67
|
+
'The portable app release must select one shared SDK event-manager module.',
|
|
68
|
+
{details: {paths}});
|
|
69
|
+
}
|
|
70
|
+
// Native hosts mount the complete app directory at one local URL origin.
|
|
71
|
+
// An origin-root URL keeps nested navigable documents on the same owner.
|
|
72
|
+
return '/' + paths[0].split('/').map(encodeURIComponent).join('/');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Shared unfinished payload assembly; the final platform provider publishes it. */
|
|
76
|
+
export async function assemblePortable(request = {}, {services, packagedWeb = false, webKitDocumentLifecycle = false} = {}) {
|
|
77
|
+
const {appDescriptor, release, signal, onEvent} = request;
|
|
78
|
+
throwIfAborted(signal);
|
|
79
|
+
const target = {...request.targetRequest};
|
|
80
|
+
const localAI = appDescriptor.native?.localAI;
|
|
81
|
+
const selectedServices = serviceSelection(services ?? appDescriptor.native?.services ?? [], release);
|
|
82
|
+
const eventOwnerModuleURL = eventOwnerPath(release);
|
|
83
|
+
const output = await outputDirectory(request);
|
|
84
|
+
throwIfAborted(signal);
|
|
85
|
+
// Unique assembly directories preserve earlier outputs and avoid
|
|
86
|
+
// overwriting an app's files or a concurrent packaging operation.
|
|
87
|
+
const root = await mkdtemp(path.join(output, 'arcane-portable-'));
|
|
88
|
+
const files = [];
|
|
89
|
+
try {
|
|
90
|
+
await emit(onEvent, {type: 'native.payload.started', appId: appDescriptor.id, outputRoot: root});
|
|
91
|
+
async function bundleSelectedRuntimes() {
|
|
92
|
+
if (localAI === undefined) return {runtimes: [], files: []};
|
|
93
|
+
const {bundleLocalAIRuntimes} = await import('../local-ai/install.mjs');
|
|
94
|
+
return bundleLocalAIRuntimes({
|
|
95
|
+
runtimes: localAI.runtimes,
|
|
96
|
+
directory: path.join(output, 'local-ai-runtimes'),
|
|
97
|
+
outputRoot: root,
|
|
98
|
+
platform: target.platform === 'windows' ? 'win32' : target.platform === 'macos' ? 'darwin' : target.platform,
|
|
99
|
+
architecture: target.architecture,
|
|
100
|
+
signal,
|
|
101
|
+
onEvent
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// These outputs have separate directories. Join both owners
|
|
105
|
+
// before cleanup so neither can keep writing a removed assembly.
|
|
106
|
+
const preparation = await Promise.allSettled([
|
|
107
|
+
copyRelease({...request, releaseRoot: request.appReleaseRoot}, 'app', root, files, signal),
|
|
108
|
+
bundleSelectedRuntimes()
|
|
109
|
+
]);
|
|
110
|
+
const failures = preparation.filter(function failedPreparation(result) { return result.status === 'rejected'; });
|
|
111
|
+
if (failures.length === 1) throw failures[0].reason;
|
|
112
|
+
if (failures.length > 1) {
|
|
113
|
+
throw new AggregateError(failures.map(function preparationError(result) { return result.reason; }),
|
|
114
|
+
'Portable application and local runtime preparation failed.');
|
|
115
|
+
}
|
|
116
|
+
const bundled = preparation[1].value;
|
|
117
|
+
files.push(...bundled.files);
|
|
118
|
+
const dependencies = [];
|
|
119
|
+
for (const [index, dependency] of (request.dependencies ?? []).entries()) {
|
|
120
|
+
const directory = `dependencies/${index}`;
|
|
121
|
+
await copyRelease(dependency, directory, root, files, signal);
|
|
122
|
+
dependencies.push({appId: dependency.appId, root: directory, release: dependency.release});
|
|
123
|
+
}
|
|
124
|
+
const sdk = await copyCoreRuntime(root, files, signal);
|
|
125
|
+
const selectedSdk = request.selectedSdk;
|
|
126
|
+
let appSdk;
|
|
127
|
+
if (selectedSdk && release.files.some(function selectedPackageFile(relative) {
|
|
128
|
+
return relative.startsWith(`${selectedSdk.packageSource}/`);
|
|
129
|
+
})) {
|
|
130
|
+
const destination = `app/${selectedSdk.packageSource}`;
|
|
131
|
+
let metadata;
|
|
132
|
+
if (release.files.includes(`${selectedSdk.packageSource}/package.json`)) {
|
|
133
|
+
metadata = JSON.parse(await readFile(outputFile(root, `${destination}/package.json`), 'utf8'));
|
|
134
|
+
} else {
|
|
135
|
+
metadata = await copyCoreRuntime(root, files, signal, {
|
|
136
|
+
sourceRoot: selectedSdk.packageRoot, sdkDestination: destination
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
appSdk = {name: metadata.name, version: metadata.version, root: destination};
|
|
140
|
+
}
|
|
141
|
+
await writeOutput(root, 'package.json', '{"type":"module"}\n', files, signal);
|
|
142
|
+
await writeOutput(root, 'runtime/arcane-core.mjs', coreEntrySource(appDescriptor, sdk.version, selectedServices,
|
|
143
|
+
{localAI, runtimes: bundled.runtimes, packagedWeb}), files, signal);
|
|
144
|
+
await writeOutput(root, 'runtime/arcane-api.js', await createCoreClassicSource({
|
|
145
|
+
eventOwnerModuleURL, replayRuntimeState: true, webKitDocumentLifecycle
|
|
146
|
+
}), files, signal);
|
|
147
|
+
const manifest = {
|
|
148
|
+
schemaVersion: 1,
|
|
149
|
+
kind: 'arcane-portable-native',
|
|
150
|
+
sdk: {name: sdk.name, version: sdk.version},
|
|
151
|
+
...(appSdk ? {appSdk} : {}),
|
|
152
|
+
app: appDescriptor,
|
|
153
|
+
target,
|
|
154
|
+
webRoot: 'app',
|
|
155
|
+
start: release.manifest?.app?.start ?? './index.html',
|
|
156
|
+
...(appDescriptor.native?.window === undefined ? {} : {window: {...appDescriptor.native.window}}),
|
|
157
|
+
...(appDescriptor.native?.launchContext === undefined ? {} : {launchContext: appDescriptor.native.launchContext}),
|
|
158
|
+
core: {
|
|
159
|
+
entry: 'runtime/arcane-core.mjs', transport: 'stdio', protocol: 'arcane/1', services: selectedServices,
|
|
160
|
+
...(localAI === undefined ? {} : {localAIRuntimes: bundled.runtimes})
|
|
161
|
+
},
|
|
162
|
+
client: {
|
|
163
|
+
source: 'runtime/arcane-api.js', injection: 'document-created', eventOwnerModuleURL, replayRuntimeState: true,
|
|
164
|
+
...(webKitDocumentLifecycle ? {webKitDocumentLifecycle: true} : {})
|
|
165
|
+
},
|
|
166
|
+
dependencies,
|
|
167
|
+
minimumCoreVersion: request.minimumCoreVersion ?? null,
|
|
168
|
+
files: [...files, MANIFEST]
|
|
169
|
+
};
|
|
170
|
+
await writeOutput(root, MANIFEST, JSON.stringify(manifest, null, 2) + '\n', files, signal);
|
|
171
|
+
throwIfAborted(signal);
|
|
172
|
+
return {app: appDescriptor, target: {...target, rootDir: root}, manifest};
|
|
173
|
+
} catch (error) {
|
|
174
|
+
try { await rm(root, {recursive: true, force: true}); }
|
|
175
|
+
catch (cleanupError) {
|
|
176
|
+
throw new AggregateError([error, cleanupError], `Portable assembly failed; its incomplete output remains at ${root}.`);
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|