@crossbind/extension-instance 0.0.0-stage → 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 +21 -0
- package/README.md +29 -2
- package/crossbind.extension.js +25 -0
- package/native/instance.cpp +56 -0
- package/native/instance.h +47 -0
- package/native/posix.cpp +317 -0
- package/native/windows.cpp +135 -0
- package/package.json +36 -3
- package/page.d.ts +11 -0
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,3 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @crossbind/extension-instance
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Keeps a desktop app to one instance, handing later launches to the one running.
|
|
4
|
+
|
|
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 ®istry) {
|
|
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
|
package/native/posix.cpp
ADDED
|
@@ -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,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossbind/extension-instance",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
]
|
|
6
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
|
+
}
|