@crossbind/extension-instance 0.0.0-bootstrap.0 → 2.0.0-beta.69

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Buğra Sarı and crossbind contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,30 @@
1
1
  # @crossbind/extension-instance
2
2
 
3
- This version, 0.0.0-bootstrap.0, holds no code. npm lets a trusted publisher be configured only for a
4
- package that exists, so it reserves the name for the crossbind release workflow.
3
+ Keeps a desktop app to one instance, handing later launches to the one running.
5
4
 
6
- Install the real package: `npm install @crossbind/extension-instance@beta`. Source: https://github.com/crossbind/crossbind
5
+ An app has it by having `@crossbind/core-app-host`, which lists it as a dependency: there is nothing to install or list.
6
+
7
+ ## Use
8
+
9
+ ```js
10
+ import { events } from 'crossbind/app';
11
+
12
+ events.on('second-instance', ({ args, cwd }) => openFrom(args, cwd));
13
+ ```
14
+
15
+ A later launch brings the running app's window to the front and hands it its arguments.
16
+
17
+ ## Options
18
+
19
+ `app.singleInstance: true` keeps the app to one instance. On Windows and Linux an app with URL schemes or file types has it
20
+ without asking, as a link or a file starts the app again there; `false` turns that off.
21
+
22
+ ## Platforms
23
+
24
+ macOS, Linux and Windows. A phone keeps every app to one instance itself.
25
+
26
+ ## Replace or leave out
27
+
28
+ It holds the role `instance`. An extension that gives `replaces: 'instance'` takes it; `app: { without: ['instance'] }` in `crossbind.config.js` leaves it out. Leaving it out while `app.singleInstance` is true stops the build. [The guide](https://crossbind.dev/guide/extensions/#replace) shows how, and [extensions.md](https://github.com/crossbind/crossbind/blob/main/docs/api/extensions.md#the-hosts-own-extensions) what a replacement answers.
29
+
30
+ [app.md](https://github.com/crossbind/crossbind/blob/main/docs/api/app.md#one-instance) has how a launch is handed over.
@@ -0,0 +1,25 @@
1
+ export default function instance() {
2
+ return {
3
+ name: 'instance',
4
+ contract: 1,
5
+ root: import.meta.dirname,
6
+ reads: ['singleInstance'],
7
+ fromApp(api) {
8
+ if (!['macos', 'linux', 'windows'].includes(api.platform)) return null;
9
+ // Windows and Linux start the app again for a link or a file; macOS hands them to the app that runs.
10
+ const startsAgain = api.platform !== 'macos' && (api.app.urlSchemes.length > 0 || api.app.fileTypes.length > 0);
11
+ const singleInstance = api.config.app.singleInstance ?? startsAgain;
12
+ if (typeof singleInstance !== 'boolean') {
13
+ throw new Error(`crossbind: app.singleInstance ${JSON.stringify(singleInstance)} is no boolean: set it to true to keep a desktop app to one instance, or to false to let each launch run.`);
14
+ }
15
+ // The app's id names the instance.
16
+ return singleInstance ? { id: api.app.id } : null;
17
+ },
18
+ app: {
19
+ sources: ['native/instance.cpp'],
20
+ macos: { sources: ['native/posix.cpp'] },
21
+ linux: { sources: ['native/posix.cpp'] },
22
+ windows: { sources: ['native/windows.cpp'] },
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,56 @@
1
+ // A desktop app kept to one instance: the launch as one instance hands it to another, and the entry, which takes the
2
+ // app's launches and leaves as the app quits.
3
+ #include "instance.h"
4
+
5
+ #include <cstdio>
6
+ #include <stdexcept>
7
+ #include <vector>
8
+
9
+ namespace crossbind::instance {
10
+
11
+ namespace {
12
+ // Names the format, which another version of the app may not share.
13
+ constexpr std::string_view LAUNCH_TAG = "crossbind launch 1\n";
14
+ } // namespace
15
+
16
+ // The tag, the size of the rest, and the token, the folder and the arguments joined by NULs, which no argument holds.
17
+ std::string encodeLaunch(const Launch &launch) {
18
+ std::string parts = launch.token + '\0' + launch.cwd;
19
+ for (const auto &arg : launch.args) (parts += '\0') += arg;
20
+ return std::string(LAUNCH_TAG) + std::to_string(parts.size()) + '\n' + parts;
21
+ }
22
+
23
+ std::optional<Launch> decodeLaunch(std::string_view bytes) {
24
+ if (bytes.size() > MAX_LAUNCH || !bytes.starts_with(LAUNCH_TAG)) return std::nullopt;
25
+ bytes.remove_prefix(LAUNCH_TAG.size());
26
+ const size_t size = bytes.find('\n');
27
+ // A launch whose process was killed while it wrote is shorter than it says.
28
+ if (size == std::string_view::npos || bytes.substr(0, size) != std::to_string(bytes.size() - size - 1)) return std::nullopt;
29
+ bytes.remove_prefix(size + 1);
30
+ std::vector<std::string> parts;
31
+ for (size_t start = 0;;) {
32
+ const size_t end = bytes.find('\0', start);
33
+ parts.emplace_back(bytes.substr(start, end == std::string_view::npos ? end : end - start));
34
+ if (end == std::string_view::npos) break;
35
+ start = end + 1;
36
+ }
37
+ if (parts.size() < 2) return std::nullopt;
38
+ return Launch{{parts.begin() + 2, parts.end()}, std::move(parts[1]), std::move(parts[0])};
39
+ }
40
+
41
+ } // namespace crossbind::instance
42
+
43
+ // The build gives the app's id, which names its instance.
44
+ void crossbind_extension_instance(crossbind::Registry &registry) {
45
+ using namespace crossbind::instance;
46
+ const std::string id = registry.options()["id"].asString();
47
+ registry.onLaunch([id](const crossbind::Launch &launch) {
48
+ const Claim claim = claimInstance(launch, id);
49
+ if (claim == Claim::NotTaken) throw std::runtime_error("the app runs already, but did not answer this launch: close it, then open the app again");
50
+ #ifdef CROSSBIND_APP_DEV
51
+ if (claim == Claim::HandedOver) std::fprintf(stderr, "crossbind: another development session of the app runs, which took this launch\n");
52
+ #endif
53
+ return claim == Claim::Instance;
54
+ });
55
+ registry.onQuit(leaveInstance);
56
+ }
@@ -0,0 +1,47 @@
1
+ #pragma once
2
+
3
+ // A desktop app that app.singleInstance keeps to one instance for each user: a later launch hands its arguments to the
4
+ // instance that runs, before the app's module starts, and leaves.
5
+ #include "crossbind/extension.h"
6
+
7
+ #include <chrono>
8
+ #include <cstddef>
9
+ #include <optional>
10
+ #include <string>
11
+ #include <string_view>
12
+
13
+ namespace crossbind::instance {
14
+
15
+ // What a launch is to the app.
16
+ enum class Claim {
17
+ // Its instance, which hears of each later launch from then on.
18
+ Instance,
19
+ // A launch the instance that runs took.
20
+ HandedOver,
21
+ // A launch the instance that runs did not take, as when it stopped answering.
22
+ NotTaken,
23
+ };
24
+
25
+ // A development session's instance is apart from the built app's, which has its id. CROSSBIND_APP_DEV marks a debug
26
+ // host, as `crossbind app dev` builds.
27
+ #ifdef CROSSBIND_APP_DEV
28
+ constexpr const char *INSTANCE_NAME = "instance-dev";
29
+ #else
30
+ constexpr const char *INSTANCE_NAME = "instance";
31
+ #endif
32
+ // Far more than a launch's arguments: anything longer is no launch.
33
+ constexpr size_t MAX_LAUNCH = 1 << 20;
34
+ // A later launch tries this long to hand itself over, waiting for one answer at a time, or to become the instance once
35
+ // the one that ran has ended.
36
+ constexpr auto CLAIM_TIME = std::chrono::seconds(10);
37
+ constexpr auto CLAIM_PAUSE = std::chrono::milliseconds(50);
38
+
39
+ // A launch as one instance hands it to the other.
40
+ std::string encodeLaunch(const Launch &launch);
41
+ std::optional<Launch> decodeLaunch(std::string_view bytes);
42
+ // Each desktop platform's, for the app of this id.
43
+ Claim claimInstance(const Launch &launch, const std::string &id);
44
+ // The instance takes no later launch from now on: each waits for it to end, and becomes the next instance.
45
+ void leaveInstance();
46
+
47
+ } // namespace crossbind::instance
@@ -0,0 +1,317 @@
1
+ // How a macOS or Linux app keeps one instance: the instance holds a lock and listens on a socket beside it, and a later
2
+ // launch that finds the lock held hands the instance its launch there.
3
+ #include "instance.h"
4
+
5
+ #include "crossbind/app.h"
6
+
7
+ #include <fcntl.h>
8
+ #include <poll.h>
9
+ #include <sys/file.h>
10
+ #include <sys/socket.h>
11
+ #include <sys/stat.h>
12
+ #include <sys/un.h>
13
+ #include <unistd.h>
14
+
15
+ #include <atomic>
16
+ #include <cerrno>
17
+ #include <cstdio>
18
+ #include <cstdlib>
19
+ #include <cstring>
20
+ #include <filesystem>
21
+ #include <system_error>
22
+ #include <thread>
23
+
24
+ namespace crossbind::instance {
25
+ namespace {
26
+
27
+ namespace chrono = std::chrono;
28
+ using Deadline = chrono::steady_clock::time_point;
29
+
30
+ // What the instance answers once it has a launch.
31
+ constexpr char TAKEN = 1;
32
+ // The instance reads one launch for this long at most, so no stalled launch holds up the next.
33
+ constexpr auto READ_TIME = chrono::seconds(1);
34
+ // An accept that failed for want of descriptors or memory is tried again after this long.
35
+ constexpr auto ACCEPT_PAUSE = chrono::milliseconds(100);
36
+ #ifdef __APPLE__
37
+ constexpr int SEND_FLAGS = 0;
38
+ #else
39
+ constexpr int SEND_FLAGS = MSG_NOSIGNAL;
40
+ #endif
41
+
42
+ const std::string LOCK_NAME = std::string(INSTANCE_NAME) + ".lock";
43
+ const std::string SOCKET_NAME = std::string(INSTANCE_NAME) + ".sock";
44
+
45
+ // The folder of the lock and the socket, and the lock, which the instance keeps while it runs.
46
+ std::string folderPath;
47
+ int folder = -1;
48
+ int lock = -1;
49
+ std::atomic<bool> isListening{false};
50
+ std::atomic<bool> isLeaving{false};
51
+
52
+ void warn(const std::string &what) { std::fprintf(stderr, "crossbind: %s\n", what.c_str()); }
53
+
54
+ std::string failure() { return std::strerror(errno); }
55
+
56
+ int millisecondsUntil(Deadline deadline) {
57
+ const auto left = chrono::duration_cast<chrono::milliseconds>(deadline - chrono::steady_clock::now()).count();
58
+ return left > 0 ? static_cast<int>(left) : 0;
59
+ }
60
+
61
+ #ifdef __linux__
62
+ // A runtime folder of the user's alone, or -1: one another user made, where the variable names a shared folder, could
63
+ // hold this app's lock.
64
+ int openOwn(const std::string &place) {
65
+ mkdir(place.c_str(), 0700);
66
+ const int opened = open(place.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
67
+ struct stat info {};
68
+ if (opened >= 0 && fstat(opened, &info) == 0 && info.st_uid == geteuid() && (info.st_mode & 077) == 0) return opened;
69
+ if (opened >= 0) close(opened);
70
+ return -1;
71
+ }
72
+ #endif
73
+
74
+ // The folder of the lock and the socket. Linux keeps them in the user's runtime folder, on this machine even where the
75
+ // home is shared over NFS, which a launch without XDG_RUNTIME_DIR, as from sudo or cron, finds as well; macOS, and Linux
76
+ // without one, in the app's cache folder.
77
+ int openFolder([[maybe_unused]] const std::string &id) {
78
+ #ifdef __linux__
79
+ const char *runtime = std::getenv("XDG_RUNTIME_DIR");
80
+ const std::string runtimeFolder = runtime && runtime[0] == '/' ? runtime : "/run/user/" + std::to_string(geteuid());
81
+ folderPath = runtimeFolder + "/" + id;
82
+ if (const int opened = openOwn(folderPath); opened >= 0) return opened;
83
+ #endif
84
+ folderPath = crossbind::app::paths().cache;
85
+ return open(folderPath.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC);
86
+ }
87
+
88
+ // Another user's process takes no launch from the socket and gives it none.
89
+ bool isSameUser(int socket) {
90
+ #ifdef __APPLE__
91
+ uid_t user = 0;
92
+ gid_t group = 0;
93
+ return getpeereid(socket, &user, &group) == 0 && user == geteuid();
94
+ #else
95
+ ucred peer{};
96
+ socklen_t size = sizeof peer;
97
+ return getsockopt(socket, SOL_SOCKET, SO_PEERCRED, &peer, &size) == 0 && peer.uid == geteuid();
98
+ #endif
99
+ }
100
+
101
+ // No write to a peer that went away ends the app with SIGPIPE.
102
+ void keepFromSigpipe([[maybe_unused]] int socket) {
103
+ #ifdef __APPLE__
104
+ const int on = 1;
105
+ setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof on);
106
+ #endif
107
+ }
108
+
109
+ int newSocket() {
110
+ const int made = socket(AF_UNIX, SOCK_STREAM, 0);
111
+ if (made < 0) return -1;
112
+ fcntl(made, F_SETFD, FD_CLOEXEC);
113
+ keepFromSigpipe(made);
114
+ return made;
115
+ }
116
+
117
+ // Binds or connects to the socket by its path where sun_path, about 100 bytes, holds it, else by its name from within
118
+ // its folder, which the process, with one thread yet, may make its working folder meanwhile.
119
+ template <typename Call>
120
+ bool atSocket(Call call) {
121
+ sockaddr_un address{};
122
+ address.sun_family = AF_UNIX;
123
+ const std::string path = folderPath + "/" + SOCKET_NAME;
124
+ const auto size = static_cast<socklen_t>(sizeof address);
125
+ if (path.size() < sizeof address.sun_path) {
126
+ std::memcpy(address.sun_path, path.c_str(), path.size() + 1);
127
+ return call(reinterpret_cast<const sockaddr *>(&address), size);
128
+ }
129
+ #ifdef O_PATH
130
+ const int here = open(".", O_PATH | O_DIRECTORY | O_CLOEXEC);
131
+ #else
132
+ const int here = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
133
+ #endif
134
+ if (here < 0) return false;
135
+ std::memcpy(address.sun_path, SOCKET_NAME.c_str(), SOCKET_NAME.size() + 1);
136
+ bool done = false;
137
+ if (fchdir(folder) == 0) {
138
+ done = call(reinterpret_cast<const sockaddr *>(&address), size);
139
+ if (fchdir(here) != 0) warn("the app cannot go back to the folder it was launched in: " + failure());
140
+ }
141
+ close(here);
142
+ return done;
143
+ }
144
+
145
+ // What a peer sends until it closes its end, within READ_TIME.
146
+ std::optional<std::string> readFrom(int peer) {
147
+ const Deadline deadline = chrono::steady_clock::now() + READ_TIME;
148
+ std::string bytes;
149
+ char chunk[4096];
150
+ for (;;) {
151
+ const int left = millisecondsUntil(deadline);
152
+ pollfd ready{peer, POLLIN, 0};
153
+ const int polled = left > 0 ? poll(&ready, 1, left) : 0;
154
+ if (polled < 0 && errno == EINTR) continue;
155
+ if (polled <= 0) return std::nullopt;
156
+ const ssize_t got = read(peer, chunk, sizeof chunk);
157
+ if (got == 0) return bytes;
158
+ if (got < 0 && errno == EINTR) continue;
159
+ if (got < 0) return std::nullopt;
160
+ bytes.append(chunk, static_cast<size_t>(got));
161
+ if (bytes.size() > MAX_LAUNCH) return std::nullopt;
162
+ }
163
+ }
164
+
165
+ void take(int peer) {
166
+ if (!isSameUser(peer)) return;
167
+ const std::optional<std::string> bytes = readFrom(peer);
168
+ std::optional<Launch> launch = bytes ? decodeLaunch(*bytes) : std::nullopt;
169
+ // An instance on its way out takes no launch, which waits for it to end and becomes the next instance. Nor does it
170
+ // take one whose process gave up on it and left, which the answer finds gone.
171
+ if (!launch || isLeaving || send(peer, &TAKEN, 1, SEND_FLAGS) != 1) return;
172
+ crossbind::launchedAgain(std::move(*launch));
173
+ }
174
+
175
+ // Lets later launches run as instances of their own.
176
+ void letGo(const std::string &why) {
177
+ warn(why + ", so later launches run as instances of their own");
178
+ isListening = false;
179
+ close(lock);
180
+ }
181
+
182
+ void serve(int listener) {
183
+ for (;;) {
184
+ #ifdef __linux__
185
+ const int peer = accept4(listener, nullptr, nullptr, SOCK_CLOEXEC);
186
+ #else
187
+ const int peer = accept(listener, nullptr, nullptr);
188
+ #endif
189
+ if (peer < 0 && (errno == EINTR || errno == ECONNABORTED)) continue;
190
+ if (peer < 0 && (errno == EMFILE || errno == ENFILE || errno == ENOBUFS || errno == ENOMEM || errno == EPROTO)) {
191
+ std::this_thread::sleep_for(ACCEPT_PAUSE);
192
+ continue;
193
+ }
194
+ if (peer < 0) {
195
+ letGo("the app stopped hearing of its later launches: " + failure());
196
+ close(listener);
197
+ return;
198
+ }
199
+ #ifndef __linux__
200
+ fcntl(peer, F_SETFD, FD_CLOEXEC);
201
+ #endif
202
+ keepFromSigpipe(peer);
203
+ try {
204
+ take(peer);
205
+ } catch (const std::exception &error) {
206
+ std::fprintf(stderr, "crossbind: the app did not take a launch: %s\n", error.what());
207
+ } catch (...) {
208
+ std::fprintf(stderr, "crossbind: the app did not take a launch\n");
209
+ }
210
+ close(peer);
211
+ }
212
+ }
213
+
214
+ // Hears of later launches on a thread of its own, or lets them run as instances of their own.
215
+ void listenForLaunches() {
216
+ // The lock is held, so a socket there is one an ended instance left.
217
+ unlinkat(folder, SOCKET_NAME.c_str(), 0);
218
+ const int listener = newSocket();
219
+ const auto bound = [listener](const sockaddr *address, socklen_t size) { return bind(listener, address, size) == 0; };
220
+ std::string why;
221
+ if (listener < 0 || !atSocket(bound) || listen(listener, SOMAXCONN) != 0) {
222
+ why = failure();
223
+ } else {
224
+ isListening = true;
225
+ try {
226
+ std::thread(serve, listener).detach();
227
+ return;
228
+ } catch (const std::system_error &error) {
229
+ why = error.what();
230
+ }
231
+ }
232
+ if (listener >= 0) close(listener);
233
+ unlinkat(folder, SOCKET_NAME.c_str(), 0);
234
+ letGo("the app cannot hear of its later launches: " + why);
235
+ }
236
+
237
+ // Hands the launch to the instance that runs, and waits for its answer on this one connection until the deadline, so
238
+ // no launch reaches it twice: true once it took the launch, false when it did not, as on its way out.
239
+ bool handOver(const std::string &bytes, Deadline deadline) {
240
+ const int left = millisecondsUntil(deadline);
241
+ const int instance = left > 0 ? newSocket() : -1;
242
+ if (instance < 0) return false;
243
+ // A launch larger than the socket's buffer would wait for an instance that has stopped reading.
244
+ const timeval sendTime{left / 1000, (left % 1000) * 1000};
245
+ setsockopt(instance, SOL_SOCKET, SO_SNDTIMEO, &sendTime, sizeof sendTime);
246
+ const auto connected = [instance](const sockaddr *address, socklen_t size) { return connect(instance, address, size) == 0; };
247
+ bool isTaken = false;
248
+ if (atSocket(connected) && isSameUser(instance)) {
249
+ size_t at = 0;
250
+ while (at < bytes.size()) {
251
+ const ssize_t put = send(instance, bytes.data() + at, bytes.size() - at, SEND_FLAGS);
252
+ if (put < 0 && errno == EINTR) continue;
253
+ if (put <= 0) break;
254
+ at += static_cast<size_t>(put);
255
+ }
256
+ if (at == bytes.size() && shutdown(instance, SHUT_WR) == 0) {
257
+ pollfd ready{instance, POLLIN, 0};
258
+ int polled = 0;
259
+ do {
260
+ polled = poll(&ready, 1, millisecondsUntil(deadline));
261
+ } while (polled < 0 && errno == EINTR);
262
+ char answer = 0;
263
+ isTaken = polled > 0 && read(instance, &answer, 1) == 1 && answer == TAKEN;
264
+ }
265
+ }
266
+ close(instance);
267
+ return isTaken;
268
+ }
269
+
270
+ } // namespace
271
+
272
+ Claim claimInstance(const Launch &launch, const std::string &id) {
273
+ try {
274
+ folder = openFolder(id);
275
+ } catch (const std::exception &error) {
276
+ std::fprintf(stderr, "%s, so the app runs as one more instance\n", error.what());
277
+ return Claim::Instance;
278
+ }
279
+ // The shell that opens the app anew inherits no descriptor, or the app it opens would find its own lock held.
280
+ lock = folder < 0 ? -1 : openat(folder, LOCK_NAME.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0600);
281
+ if (lock < 0) {
282
+ warn("the app cannot lock " + folderPath + ": " + failure() + ", so it runs as one more instance");
283
+ return Claim::Instance;
284
+ }
285
+ const std::string bytes = encodeLaunch(launch);
286
+ const Deadline deadline = chrono::steady_clock::now() + CLAIM_TIME;
287
+ for (;;) {
288
+ // The instance keeps the lock while it runs, and the system lets go of it however the instance ends.
289
+ if (flock(lock, LOCK_EX | LOCK_NB) == 0) {
290
+ listenForLaunches();
291
+ return Claim::Instance;
292
+ }
293
+ if (errno != EWOULDBLOCK) {
294
+ warn("the app cannot lock " + folderPath + ": " + failure() + ", so it runs as one more instance");
295
+ close(lock);
296
+ return Claim::Instance;
297
+ }
298
+ if (bytes.size() > MAX_LAUNCH) {
299
+ warn("this launch's arguments are too long to hand to the app that runs");
300
+ return Claim::NotTaken;
301
+ }
302
+ if (handOver(bytes, deadline)) return Claim::HandedOver;
303
+ if (millisecondsUntil(deadline) == 0) {
304
+ warn("the app runs already, but did not take this launch");
305
+ return Claim::NotTaken;
306
+ }
307
+ std::this_thread::sleep_for(CLAIM_PAUSE);
308
+ }
309
+ }
310
+
311
+ void leaveInstance() {
312
+ isLeaving = true;
313
+ // A later launch then finds no socket, and waits for the lock.
314
+ if (isListening) unlinkat(folder, SOCKET_NAME.c_str(), 0);
315
+ }
316
+
317
+ } // namespace crossbind::instance
@@ -0,0 +1,135 @@
1
+ // How a Windows app keeps one instance: the instance holds a named mutex and a message-only window, which a later launch,
2
+ // finding the mutex, sends its launch to.
3
+ #include "instance.h"
4
+
5
+ #include <windows.h>
6
+
7
+ #include <atomic>
8
+ #include <cstdio>
9
+ #include <future>
10
+ #include <system_error>
11
+ #include <thread>
12
+
13
+ namespace crossbind::instance {
14
+ namespace {
15
+
16
+ // Marks a launch among the WM_COPYDATA messages the window may get.
17
+ constexpr ULONG_PTR LAUNCH = 0x63626c6e;
18
+
19
+ std::atomic<bool> isLeaving{false};
20
+
21
+ void warn(const std::string &what) { std::fprintf(stderr, "crossbind: %s\n", what.c_str()); }
22
+
23
+ std::wstring wide(const std::string &text) {
24
+ if (text.empty()) return {};
25
+ const int size = MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0);
26
+ std::wstring result(static_cast<size_t>(size), L'\0');
27
+ MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), result.data(), size);
28
+ return result;
29
+ }
30
+
31
+ LRESULT CALLBACK instanceProcedure(HWND window, UINT message, WPARAM wParam, LPARAM lParam) {
32
+ if (message != WM_COPYDATA) return DefWindowProcW(window, message, wParam, lParam);
33
+ const auto *data = reinterpret_cast<const COPYDATASTRUCT *>(lParam);
34
+ // An instance on its way out takes no launch, which waits for it to end and becomes the next instance.
35
+ if (!data || data->dwData != LAUNCH || isLeaving) return FALSE;
36
+ // No exception crosses the window procedure.
37
+ try {
38
+ std::optional<Launch> launch = decodeLaunch({static_cast<const char *>(data->lpData), data->cbData});
39
+ if (!launch) return FALSE;
40
+ // The launch that sent it leaves at once, as on macOS and Linux, whatever the app then does with it on the main
41
+ // thread. The data is copied, as the sender may free it from now on.
42
+ ReplyMessage(TRUE);
43
+ crossbind::launchedAgain(std::move(*launch));
44
+ return TRUE;
45
+ } catch (const std::exception &error) {
46
+ std::fprintf(stderr, "crossbind: the app did not take a launch: %s\n", error.what());
47
+ } catch (...) {
48
+ std::fprintf(stderr, "crossbind: the app did not take a launch\n");
49
+ }
50
+ return FALSE;
51
+ }
52
+
53
+ // Makes the window later launches find, and answers them on a thread of its own, as the main thread may be busy starting
54
+ // the app or ending it; false when it cannot.
55
+ bool answerLaunches(const std::wstring &name) {
56
+ std::promise<bool> made;
57
+ std::future<bool> isMade = made.get_future();
58
+ try {
59
+ std::thread([name, made = std::move(made)]() mutable {
60
+ WNDCLASSEXW type{};
61
+ type.cbSize = sizeof type;
62
+ type.lpfnWndProc = instanceProcedure;
63
+ type.hInstance = GetModuleHandleW(nullptr);
64
+ type.lpszClassName = name.c_str();
65
+ const bool isMadeHere = RegisterClassExW(&type) &&
66
+ CreateWindowExW(0, name.c_str(), L"", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, type.hInstance, nullptr);
67
+ made.set_value(isMadeHere);
68
+ MSG message;
69
+ while (isMadeHere && GetMessageW(&message, nullptr, 0, 0) > 0) DispatchMessageW(&message);
70
+ }).detach();
71
+ } catch (const std::system_error &) {
72
+ return false;
73
+ }
74
+ return isMade.get();
75
+ }
76
+
77
+ // Sends the launch to the instance's window, and waits for its answer until the deadline, so no launch reaches it twice:
78
+ // true once the instance took it, false when it did not, as on its way out.
79
+ bool handOver(const std::wstring &name, const std::string &bytes, std::chrono::steady_clock::time_point deadline) {
80
+ const HWND window = FindWindowExW(HWND_MESSAGE, nullptr, name.c_str(), nullptr);
81
+ const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - std::chrono::steady_clock::now()).count();
82
+ if (!window || left <= 0) return false;
83
+ DWORD instance = 0;
84
+ GetWindowThreadProcessId(window, &instance);
85
+ // The user just started this launch, which lets it give the instance the front.
86
+ AllowSetForegroundWindow(instance);
87
+ COPYDATASTRUCT data{LAUNCH, static_cast<DWORD>(bytes.size()), const_cast<char *>(bytes.data())};
88
+ DWORD_PTR isTaken = FALSE;
89
+ return SendMessageTimeoutW(window, WM_COPYDATA, 0, reinterpret_cast<LPARAM>(&data), SMTO_NORMAL, static_cast<UINT>(left),
90
+ &isTaken) &&
91
+ isTaken == TRUE;
92
+ }
93
+
94
+ } // namespace
95
+
96
+ Claim claimInstance(const Launch &launch, const std::string &id) {
97
+ // Each signed-in user's session has its own; the name also sets a development session apart.
98
+ const std::wstring name = wide(id + "." + INSTANCE_NAME);
99
+ const std::wstring mutexName = L"Local\\" + name;
100
+ const std::string bytes = encodeLaunch(launch);
101
+ const auto deadline = std::chrono::steady_clock::now() + CLAIM_TIME;
102
+ for (;;) {
103
+ const HANDLE mutex = CreateMutexW(nullptr, FALSE, mutexName.c_str());
104
+ const DWORD error = GetLastError();
105
+ if (!mutex) {
106
+ warn("the app cannot make its mutex, error " + std::to_string(error) + ", so it runs as one more instance");
107
+ return Claim::Instance;
108
+ }
109
+ // The instance keeps the mutex while it runs.
110
+ if (error != ERROR_ALREADY_EXISTS) {
111
+ if (!answerLaunches(name)) {
112
+ CloseHandle(mutex);
113
+ warn("the app cannot hear of its later launches, so they run as instances of their own");
114
+ }
115
+ return Claim::Instance;
116
+ }
117
+ if (bytes.size() > MAX_LAUNCH) {
118
+ warn("this launch's arguments are too long to hand to the app that runs");
119
+ return Claim::NotTaken;
120
+ }
121
+ const bool isTaken = handOver(name, bytes, deadline);
122
+ // The mutex lasts while any handle to it is open, this launch's as well.
123
+ CloseHandle(mutex);
124
+ if (isTaken) return Claim::HandedOver;
125
+ if (std::chrono::steady_clock::now() >= deadline) {
126
+ warn("the app runs already, but did not take this launch");
127
+ return Claim::NotTaken;
128
+ }
129
+ Sleep(static_cast<DWORD>(CLAIM_PAUSE.count()));
130
+ }
131
+ }
132
+
133
+ void leaveInstance() { isLeaving = true; }
134
+
135
+ } // namespace crossbind::instance
package/package.json CHANGED
@@ -1,7 +1,39 @@
1
1
  {
2
- "name": "@crossbind/extension-instance",
3
- "version": "0.0.0-bootstrap.0",
4
- "description": "Placeholder that let npm trust the crossbind release workflow; install @crossbind/extension-instance@beta.",
5
- "license": "MIT",
6
- "repository": "https://github.com/crossbind/crossbind.git"
7
- }
2
+ "name": "@crossbind/extension-instance",
3
+ "version": "2.0.0-beta.69",
4
+ "description": "crossbind app extension: keeps a desktop app to one instance, handing later launches to the one running.",
5
+ "homepage": "https://github.com/crossbind/crossbind/tree/main/extensions/instance#readme",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/crossbind/crossbind.git"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "exports": {
13
+ "./page": {
14
+ "types": "./page.d.ts"
15
+ },
16
+ "./crossbind.extension.js": "./crossbind.extension.js"
17
+ },
18
+ "files": [
19
+ "crossbind.extension.js",
20
+ "native",
21
+ "page.d.ts"
22
+ ],
23
+ "crossbind": {
24
+ "extension": "./crossbind.extension.js"
25
+ },
26
+ "peerDependencies": {
27
+ "crossbind": "^2.0.0-beta.69"
28
+ },
29
+ "devDependencies": {
30
+ "crossbind": "^2.0.0-beta.69"
31
+ },
32
+ "keywords": [
33
+ "crossbind-extension",
34
+ "crossbind",
35
+ "launch",
36
+ "instance",
37
+ "app"
38
+ ]
39
+ }
package/page.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Launch } from 'crossbind/app/core';
2
+
3
+ /** What the event second-instance carries: a later launch. */
4
+ export type SecondInstance = Launch;
5
+
6
+ declare module 'crossbind/app/core' {
7
+ interface AppEvents {
8
+ /** Calls listener with each later launch of a desktop app that app.singleInstance keeps to one instance, which brings its window to the front as far as the system lets it, until the function it returns is called. */
9
+ 'second-instance': SecondInstance;
10
+ }
11
+ }