@crossbind/extension-shell 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-shell
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
+ Opens addresses and files outside the app, shows them in the file manager, moves them to the trash.
5
4
 
6
- Install the real package: `npm install @crossbind/extension-shell@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 { shell } from 'crossbind/app';
11
+
12
+ await shell.openExternal('https://crossbind.dev');
13
+ await shell.revealInFolder(path);
14
+ ```
15
+
16
+ ## Options
17
+
18
+ `app.openPath` lists where `shell.openPath` may open a file, of `data`, `cache`, `logs` and `picked`: nowhere without
19
+ it, as opening a program runs it. `shell.revealInFolder` and `shell.trash` reach the app's own folders and what a dialog
20
+ picked during this run. The app's native code, calling these by name with `invoke()`, reaches any path.
21
+
22
+ ## Platforms
23
+
24
+ `shell.openExternal` on macOS, Linux, Windows and iOS; the others on the desktop alone.
25
+
26
+ ## Replace or leave out
27
+
28
+ It holds the role `shell`. An extension that gives `replaces: 'shell'` takes it; `app: { without: ['shell'] }` in `crossbind.config.js` leaves it out. Leaving it out while `app.openPath` lists a place 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#the-apps-functions) has each function.
@@ -0,0 +1,33 @@
1
+ // What app.openPath can let the page's shell.openPath open: the app's folders as app.paths() names them, and what a
2
+ // dialog picked during this run.
3
+ const OPEN_PATH_PLACES = ['data', 'cache', 'logs', 'picked'];
4
+
5
+ // Where the page's shell.openPath opens a file: nowhere unless app.openPath lists it, as opening a program runs it.
6
+ function checkedOpenPath(places) {
7
+ if (!Array.isArray(places)) {
8
+ throw new Error(`crossbind: app.openPath ${JSON.stringify(places)} is no list of ${OPEN_PATH_PLACES.join(', ')}, as ['logs', 'picked'] is.`);
9
+ }
10
+ const unknown = places.filter((place) => !OPEN_PATH_PLACES.includes(place));
11
+ if (unknown.length > 0) {
12
+ throw new Error(`crossbind: app.openPath takes ${OPEN_PATH_PLACES.join(', ')}; ${unknown.map((place) => JSON.stringify(place)).join(', ')} is none of them.`);
13
+ }
14
+ return [...places];
15
+ }
16
+
17
+ export default function shell() {
18
+ return {
19
+ name: 'shell',
20
+ contract: 1,
21
+ root: import.meta.dirname,
22
+ reads: ['openPath'],
23
+ fromApp: (api) => ({ openPath: checkedOpenPath(api.config.app.openPath ?? []) }),
24
+ app: {
25
+ sources: ['native/shell.cpp'],
26
+ macos: { sources: ['native/darwin.mm'] },
27
+ ios: { sources: ['native/ios.mm'] },
28
+ linux: { sources: ['native/linux.cpp'] },
29
+ windows: { sources: ['native/windows.cpp'], libraries: ['shell32', 'ole32'] },
30
+ android: { sources: ['native/android.cpp'] },
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,16 @@
1
+ // The shell on Android, which has none of its functions yet.
2
+ #include "shell.h"
3
+
4
+ #include <stdexcept>
5
+
6
+ namespace crossbind::shell {
7
+
8
+ void openUrl(std::string, Done) { throw std::runtime_error("crossbind: an Android app opens no address outside itself yet"); }
9
+
10
+ void openFile(std::string, Done) { throw std::runtime_error("crossbind: an Android app opens no file in another app yet"); }
11
+
12
+ void revealFile(std::string, Done) { throw std::runtime_error("crossbind: an Android app has no file manager to show a file in"); }
13
+
14
+ void trashFile(std::string, Done) { throw std::runtime_error("crossbind: an Android app has no trash to move a file to"); }
15
+
16
+ } // namespace crossbind::shell
@@ -0,0 +1,69 @@
1
+ // The shell on macOS, which the workspace and Finder answer.
2
+ #import <Cocoa/Cocoa.h>
3
+
4
+ #include "shell.h"
5
+
6
+ namespace crossbind::shell {
7
+ namespace {
8
+
9
+ using Failure = std::optional<std::string>;
10
+
11
+ NSString *ns(const std::string &text) { return [NSString stringWithUTF8String:text.c_str()] ?: @""; }
12
+
13
+ bool exists(NSURL *file) { return [NSFileManager.defaultManager fileExistsAtPath:file.path]; }
14
+
15
+ } // namespace
16
+
17
+ void openUrl(std::string url, Done done) {
18
+ onMainThread([url = std::move(url), done = std::move(done)] {
19
+ NSURL *address = [NSURL URLWithString:ns(url)];
20
+ if (!address) {
21
+ done("crossbind: " + url + " is no address");
22
+ return;
23
+ }
24
+ done([NSWorkspace.sharedWorkspace openURL:address] ? Failure() : Failure("crossbind: nothing opens " + url));
25
+ });
26
+ }
27
+
28
+ void openFile(std::string path, Done done) {
29
+ onMainThread([path = std::move(path), done = std::move(done)] {
30
+ NSURL *file = [NSURL fileURLWithPath:ns(path)];
31
+ if (!exists(file)) {
32
+ done("crossbind: there is no " + path);
33
+ return;
34
+ }
35
+ done([NSWorkspace.sharedWorkspace openURL:file] ? Failure() : Failure("crossbind: nothing opens " + path));
36
+ });
37
+ }
38
+
39
+ void revealFile(std::string path, Done done) {
40
+ onMainThread([path = std::move(path), done = std::move(done)] {
41
+ NSURL *file = [NSURL fileURLWithPath:ns(path)];
42
+ if (!exists(file)) {
43
+ done("crossbind: there is no " + path);
44
+ return;
45
+ }
46
+ [NSWorkspace.sharedWorkspace activateFileViewerSelectingURLs:@[ file ]];
47
+ done(std::nullopt);
48
+ });
49
+ }
50
+
51
+ // A volume without a trash fails it rather than deleting the file.
52
+ void trashFile(std::string path, Done done) {
53
+ onMainThread([path = std::move(path), done = std::move(done)] {
54
+ NSURL *file = [NSURL fileURLWithPath:ns(path)];
55
+ if (!exists(file)) {
56
+ done("crossbind: there is no " + path);
57
+ return;
58
+ }
59
+ NSError *error = nil;
60
+ if ([NSFileManager.defaultManager trashItemAtURL:file resultingItemURL:nil error:&error]) {
61
+ done(std::nullopt);
62
+ } else {
63
+ NSString *why = error.localizedDescription;
64
+ done("crossbind: cannot move " + path + " to the trash: " + std::string(why ? why.UTF8String : "macOS gave no reason"));
65
+ }
66
+ });
67
+ }
68
+
69
+ } // namespace crossbind::shell
package/native/ios.mm ADDED
@@ -0,0 +1,29 @@
1
+ // The shell on iOS, which opens addresses alone.
2
+ #import <UIKit/UIKit.h>
3
+
4
+ #include "shell.h"
5
+
6
+ #include <stdexcept>
7
+
8
+ namespace crossbind::shell {
9
+
10
+ void openUrl(std::string url, Done done) {
11
+ onMainThread([url = std::move(url), done = std::move(done)] {
12
+ NSURL *address = [NSURL URLWithString:[NSString stringWithUTF8String:url.c_str()] ?: @""];
13
+ if (!address) {
14
+ done("crossbind: " + url + " is no address");
15
+ return;
16
+ }
17
+ [UIApplication.sharedApplication openURL:address options:@{} completionHandler:^(BOOL opened) {
18
+ done(opened ? std::optional<std::string>() : std::optional<std::string>("crossbind: nothing opens " + url));
19
+ }];
20
+ });
21
+ }
22
+
23
+ void openFile(std::string, Done) { throw std::runtime_error("crossbind: an iOS app opens no file in another app yet"); }
24
+
25
+ void revealFile(std::string, Done) { throw std::runtime_error("crossbind: an iOS app has no file manager to show a file in"); }
26
+
27
+ void trashFile(std::string, Done) { throw std::runtime_error("crossbind: an iOS app has no trash to move a file to"); }
28
+
29
+ } // namespace crossbind::shell
@@ -0,0 +1,108 @@
1
+ // The shell on Linux, which GIO and the desktop's file manager answer.
2
+ #include "shell.h"
3
+
4
+ #include <gio/gio.h>
5
+
6
+ #include <memory>
7
+
8
+ namespace crossbind::shell {
9
+ namespace {
10
+
11
+ using Failure = std::optional<std::string>;
12
+
13
+ // What the system opens a URI with, or why it cannot.
14
+ Failure launch(const std::string &uri, const std::string &what) {
15
+ GError *error = nullptr;
16
+ if (g_app_info_launch_default_for_uri(uri.c_str(), nullptr, &error)) return std::nullopt;
17
+ const std::string reason = error ? error->message : "nothing opens it";
18
+ g_clear_error(&error);
19
+ return "crossbind: cannot open " + what + ": " + reason;
20
+ }
21
+
22
+ // A file revealFile shows, until the file manager answers.
23
+ struct Reveal {
24
+ std::string path;
25
+ Done done;
26
+ };
27
+
28
+ void openFolderOf(const Reveal &reveal) {
29
+ gchar *folder = g_path_get_dirname(reveal.path.c_str());
30
+ gchar *folderUri = g_filename_to_uri(folder, nullptr, nullptr);
31
+ reveal.done(folderUri ? launch(folderUri, folder) : Failure("crossbind: there is no folder of " + reveal.path));
32
+ g_free(folderUri);
33
+ g_free(folder);
34
+ }
35
+
36
+ } // namespace
37
+
38
+ void openUrl(std::string url, Done done) {
39
+ onMainThread([url = std::move(url), done = std::move(done)] { done(launch(url, url)); });
40
+ }
41
+
42
+ void openFile(std::string path, Done done) {
43
+ onMainThread([path = std::move(path), done = std::move(done)] {
44
+ gchar *uri = g_file_test(path.c_str(), G_FILE_TEST_EXISTS) ? g_filename_to_uri(path.c_str(), nullptr, nullptr) : nullptr;
45
+ if (!uri) {
46
+ done("crossbind: there is no " + path);
47
+ return;
48
+ }
49
+ done(launch(uri, path));
50
+ g_free(uri);
51
+ });
52
+ }
53
+
54
+ // A mount without a trash, as a network share may be, fails it rather than deleting the file.
55
+ void trashFile(std::string path, Done done) {
56
+ onMainThread([path = std::move(path), done = std::move(done)] {
57
+ if (!g_file_test(path.c_str(), G_FILE_TEST_EXISTS)) {
58
+ done("crossbind: there is no " + path);
59
+ return;
60
+ }
61
+ GFile *file = g_file_new_for_path(path.c_str());
62
+ GError *error = nullptr;
63
+ if (g_file_trash(file, nullptr, &error)) {
64
+ done(std::nullopt);
65
+ } else {
66
+ done("crossbind: cannot move " + path + " to the trash: " + error->message);
67
+ g_error_free(error);
68
+ }
69
+ g_object_unref(file);
70
+ });
71
+ }
72
+
73
+ // The file managers of GNOME, KDE and others show a file selected when asked over D-Bus; without one, its folder opens.
74
+ // The answer comes back to the loop, which a file manager that has to start first would hold up otherwise.
75
+ void revealFile(std::string path, Done done) {
76
+ onMainThread([path = std::move(path), done = std::move(done)]() mutable {
77
+ gchar *uri = g_file_test(path.c_str(), G_FILE_TEST_EXISTS) ? g_filename_to_uri(path.c_str(), nullptr, nullptr) : nullptr;
78
+ if (!uri) {
79
+ done("crossbind: there is no " + path);
80
+ return;
81
+ }
82
+ auto reveal = std::make_unique<Reveal>(Reveal{std::move(path), std::move(done)});
83
+ GDBusConnection *bus = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, nullptr);
84
+ if (!bus) {
85
+ openFolderOf(*reveal);
86
+ g_free(uri);
87
+ return;
88
+ }
89
+ const gchar *uris[] = {uri, nullptr};
90
+ g_dbus_connection_call(bus, "org.freedesktop.FileManager1", "/org/freedesktop/FileManager1", "org.freedesktop.FileManager1",
91
+ "ShowItems", g_variant_new("(^ass)", uris, ""), nullptr, G_DBUS_CALL_FLAGS_NONE, -1, nullptr,
92
+ [](GObject *source, GAsyncResult *result, gpointer data) {
93
+ const std::unique_ptr<Reveal> reveal(static_cast<Reveal *>(data));
94
+ GVariant *shown = g_dbus_connection_call_finish(G_DBUS_CONNECTION(source), result, nullptr);
95
+ if (!shown) {
96
+ openFolderOf(*reveal);
97
+ return;
98
+ }
99
+ g_variant_unref(shown);
100
+ reveal->done(std::nullopt);
101
+ },
102
+ reveal.release());
103
+ g_object_unref(bus);
104
+ g_free(uri);
105
+ });
106
+ }
107
+
108
+ } // namespace crossbind::shell
@@ -0,0 +1,195 @@
1
+ // The ways out of the app the page calls through crossbind/app's shell, and the app's own code by their names:
2
+ // an address in its browser, a file in its app, one in the file manager and one in the trash. The page reaches the places
3
+ // it may alone; the app's own code reaches any path.
4
+ #include "shell.h"
5
+
6
+ #include "crossbind/app.h"
7
+
8
+ #include <algorithm>
9
+ #include <cctype>
10
+ #include <cstdio>
11
+ #include <filesystem>
12
+ #include <stdexcept>
13
+ #include <vector>
14
+
15
+ namespace crossbind::shell {
16
+ namespace {
17
+
18
+ namespace fs = std::filesystem;
19
+
20
+ fs::path pathOf(const std::string &text) { return fs::path(std::u8string(text.begin(), text.end())); }
21
+
22
+ // ASCII alone, whatever locale GTK sets.
23
+ char lower(char c) { return c >= 'A' && c <= 'Z' ? static_cast<char>(c - 'A' + 'a') : c; }
24
+
25
+ // An address opens outside the app only when it is one a browser or a mail app takes. It goes on in ASCII, with a space,
26
+ // a quote, any other byte and a % that escapes nothing escaped: every system opens it alike, no handler's command line
27
+ // splits it, and Windows finds no %VARIABLE% to expand in it.
28
+ std::string checkedUrl(const std::string &url) {
29
+ const auto colon = url.find(':');
30
+ std::string scheme = url.substr(0, colon == std::string::npos ? 0 : colon);
31
+ std::transform(scheme.begin(), scheme.end(), scheme.begin(), lower);
32
+ const bool hasControl = std::any_of(url.begin(), url.end(), [](unsigned char c) { return c < ' ' || c == 0x7f; });
33
+ if ((scheme != "http" && scheme != "https" && scheme != "mailto") || hasControl) {
34
+ throw std::invalid_argument("crossbind: openExternal opens http, https and mailto addresses alone, not " + url);
35
+ }
36
+ std::string escaped;
37
+ for (size_t i = 0; i < url.size(); i += 1) {
38
+ const unsigned char c = url[i];
39
+ const bool isEscape = c == '%' && i + 2 < url.size() && std::isxdigit(static_cast<unsigned char>(url[i + 1])) &&
40
+ std::isxdigit(static_cast<unsigned char>(url[i + 2]));
41
+ if (c < 0x80 && c != ' ' && c != '"' && (c != '%' || isEscape)) {
42
+ escaped += static_cast<char>(c);
43
+ continue;
44
+ }
45
+ char hex[4];
46
+ std::snprintf(hex, sizeof hex, "%%%02X", c);
47
+ escaped += hex;
48
+ }
49
+ return escaped;
50
+ }
51
+
52
+ // A relative path would mean the host's own working folder.
53
+ void checkPath(const std::string &path, const std::string &function) {
54
+ if (path.empty() || path.find('\0') != std::string::npos || !pathOf(path).is_absolute()) {
55
+ throw std::invalid_argument("crossbind: " + function + " takes an absolute path, not \"" + path + "\"");
56
+ }
57
+ }
58
+
59
+ // A path as written, without a trailing separator: comparing never touches the disk or a network share.
60
+ fs::path normal(std::string text) {
61
+ #ifdef _WIN32
62
+ // Windows names a file in any case.
63
+ std::transform(text.begin(), text.end(), text.begin(), lower);
64
+ #endif
65
+ const fs::path path = pathOf(text).lexically_normal();
66
+ return path.has_filename() ? path : path.parent_path();
67
+ }
68
+
69
+ bool isWithin(const fs::path &path, const fs::path &folder) {
70
+ return std::mismatch(folder.begin(), folder.end(), path.begin(), path.end()).first == folder.end();
71
+ }
72
+
73
+ // What the app's dialogs picked during this run, as normal() writes it.
74
+ std::vector<fs::path> picked() {
75
+ std::vector<fs::path> out;
76
+ for (const auto &path : allowedPaths()) out.push_back(normal(path));
77
+ return out;
78
+ }
79
+
80
+ // The page reaches places by name: the app's folders as app.paths() names them, and "picked", what its dialogs picked, a
81
+ // picked folder with what is in it.
82
+ void checkScope(const std::string &path, const char *function, const std::vector<std::string> &places, const char *rule) {
83
+ // The system follows a link before a .., which leads it elsewhere than the path reads.
84
+ for (const auto &part : pathOf(path)) {
85
+ if (part == "." || part == "..") throw std::invalid_argument(std::string("crossbind: the page's ") + function + " takes a path without . or .., not " + path);
86
+ }
87
+ const fs::path asked = normal(path);
88
+ const app::Paths folders = app::paths();
89
+ std::vector<fs::path> allowed;
90
+ for (const auto &place : places) {
91
+ if (place == "data") allowed.push_back(normal(folders.data));
92
+ if (place == "cache") allowed.push_back(normal(folders.cache));
93
+ if (place == "logs") allowed.push_back(normal(folders.logs));
94
+ if (place == "picked") {
95
+ const std::vector<fs::path> paths = picked();
96
+ allowed.insert(allowed.end(), paths.begin(), paths.end());
97
+ }
98
+ }
99
+ if (std::none_of(allowed.begin(), allowed.end(), [&](const fs::path &folder) { return isWithin(asked, folder); })) {
100
+ throw std::invalid_argument(std::string("crossbind: the page's ") + function + " " + rule + ", not " + path +
101
+ "; the app's native code reaches any path by invoke()");
102
+ }
103
+ }
104
+
105
+ // Where a path written as normal() writes it leads, the links of the folders above it followed; the system's trash
106
+ // follows those and moves the last name itself, a link as well. None when the system cannot tell.
107
+ std::optional<fs::path> followedParent(const fs::path &path) {
108
+ std::error_code error;
109
+ const std::u8string parent = fs::weakly_canonical(path.parent_path(), error).u8string();
110
+ if (error) return std::nullopt;
111
+ return normal(std::string(parent.begin(), parent.end())) / path.filename();
112
+ }
113
+
114
+ // A folder written as normal() writes it, its links followed.
115
+ fs::path followed(const fs::path &folder) {
116
+ std::error_code error;
117
+ const std::u8string real = fs::weakly_canonical(folder, error).u8string();
118
+ return error ? folder : normal(std::string(real.begin(), real.end()));
119
+ }
120
+
121
+ // The page's trash takes what is in the app's folders, but none of the folders, and what its dialogs picked, both as
122
+ // the path is written and where it leads.
123
+ void checkTrash(const std::string &path) {
124
+ const char *rule = "trashes what is in the app's folders and what its dialogs picked";
125
+ checkScope(path, "shell.trash", {"data", "cache", "logs", "picked"}, rule);
126
+ const std::optional<fs::path> asked = followedParent(normal(path));
127
+ if (!asked) throw std::invalid_argument("crossbind: the page's shell.trash cannot tell where " + path + " leads");
128
+ const app::Paths folders = app::paths();
129
+ std::vector<fs::path> allowed;
130
+ for (const auto &folder : {folders.data, folders.cache, folders.logs}) {
131
+ allowed.push_back(followed(normal(folder)));
132
+ if (normal(path) == normal(folder) || *asked == allowed.back()) {
133
+ throw std::invalid_argument("crossbind: the page's shell.trash takes what is in the app's folders, not " + path + " itself");
134
+ }
135
+ }
136
+ // A picked folder's own link is followed as well, and a picked file's is not.
137
+ for (const auto &item : picked()) {
138
+ allowed.push_back(followed(item));
139
+ if (const auto parent = followedParent(item)) allowed.push_back(*parent);
140
+ }
141
+ if (std::none_of(allowed.begin(), allowed.end(), [&](const fs::path &folder) { return isWithin(*asked, folder); })) {
142
+ throw std::invalid_argument(std::string("crossbind: the page's shell.trash ") + rule + ", not " + path + ", which a link leads out of them");
143
+ }
144
+ }
145
+
146
+ Done answered(Call call) {
147
+ return [call = std::move(call)](std::optional<std::string> failure) {
148
+ if (failure) call.reject(*failure);
149
+ else call.resolve();
150
+ };
151
+ }
152
+
153
+ // The places app.openPath lists, which the build checked.
154
+ std::vector<std::string> placesOf(const Value &list) {
155
+ std::vector<std::string> out;
156
+ if (list.isNull()) return out;
157
+ for (const Value &place : list.asArray()) out.push_back(place.asString());
158
+ return out;
159
+ }
160
+
161
+ // The absolute path a call names: the page's call names its function as crossbind/app does, the app's own code's by the
162
+ // function's own name.
163
+ std::string pathIn(const Call &call, const char *function) {
164
+ const std::string path = call.options()["path"].asString("");
165
+ checkPath(path, call.isFromPage() ? std::string("shell.") + function : std::string(function));
166
+ return path;
167
+ }
168
+
169
+ } // namespace
170
+ } // namespace crossbind::shell
171
+
172
+ void crossbind_extension_shell(crossbind::Registry &registry) {
173
+ using namespace crossbind::shell;
174
+ using crossbind::Call;
175
+ // Opening a program runs it: the page opens files where the app lets it alone.
176
+ const std::vector<std::string> openPlaces = placesOf(registry.options()["openPath"]);
177
+ registry.function("shell.openExternal", [](Call call) { openUrl(checkedUrl(call.options()["url"].asString("")), answered(call)); });
178
+ registry.function("shell.openPath", [openPlaces](Call call) {
179
+ const std::string path = pathIn(call, "openPath");
180
+ if (call.isFromPage()) {
181
+ checkScope(path, "shell.openPath", openPlaces, "opens what app.openPath in crossbind.config.js lists of data, cache, logs and picked");
182
+ }
183
+ openFile(path, answered(call));
184
+ });
185
+ registry.function("shell.revealInFolder", [](Call call) {
186
+ const std::string path = pathIn(call, "revealInFolder");
187
+ if (call.isFromPage()) checkScope(path, "shell.revealInFolder", {"data", "cache", "logs", "picked"}, "reaches the app's folders and what its dialogs picked");
188
+ revealFile(path, answered(call));
189
+ });
190
+ registry.function("shell.trash", [](Call call) {
191
+ const std::string path = pathIn(call, "trash");
192
+ if (call.isFromPage()) checkTrash(path);
193
+ trashFile(path, answered(call));
194
+ });
195
+ }
package/native/shell.h ADDED
@@ -0,0 +1,22 @@
1
+ #pragma once
2
+
3
+ // What each platform does for the shell. Each is called on the module's thread, works on the main thread and calls done
4
+ // there with why it failed, or nothing. A platform that has no such function throws.
5
+ #include "crossbind/extension.h"
6
+
7
+ #include <functional>
8
+ #include <optional>
9
+ #include <string>
10
+
11
+ namespace crossbind::shell {
12
+
13
+ using Done = std::function<void(std::optional<std::string>)>;
14
+
15
+ // An http, https or mailto address, which the caller has checked.
16
+ void openUrl(std::string url, Done done);
17
+ void openFile(std::string path, Done done);
18
+ void revealFile(std::string path, Done done);
19
+ // Moves a file or folder to the system's trash. Where it has no trash, as on a network share, it moves nothing and fails.
20
+ void trashFile(std::string path, Done done);
21
+
22
+ } // namespace crossbind::shell
@@ -0,0 +1,152 @@
1
+ // The shell on Windows, which the shell's own functions and Explorer answer.
2
+ #include "shell.h"
3
+
4
+ #include <windows.h>
5
+
6
+ #include <shellapi.h>
7
+ #include <shlobj.h>
8
+ #include <shobjidl.h>
9
+
10
+ #include <algorithm>
11
+ #include <atomic>
12
+ #include <cstdio>
13
+
14
+ namespace crossbind::shell {
15
+ namespace {
16
+
17
+ using Failure = std::optional<std::string>;
18
+
19
+ std::wstring wide(const std::string &text) {
20
+ if (text.empty()) return {};
21
+ const int size = MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0);
22
+ std::wstring result(static_cast<size_t>(size), L'\0');
23
+ MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), result.data(), size);
24
+ return result;
25
+ }
26
+
27
+ bool exists(const std::string &path) { return GetFileAttributesW(wide(path).c_str()) != INVALID_FILE_ATTRIBUTES; }
28
+
29
+ // Aborts a deletion that would not go to the Recycle Bin, as IFileOperation makes of an item on a drive without one,
30
+ // such as a network share: it would delete the item for good.
31
+ class RecycleOnly final : public IFileOperationProgressSink {
32
+ public:
33
+ ULONG STDMETHODCALLTYPE AddRef() override { return ++refs_; }
34
+ ULONG STDMETHODCALLTYPE Release() override {
35
+ const ULONG left = --refs_;
36
+ if (left == 0) delete this;
37
+ return left;
38
+ }
39
+ HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **out) override {
40
+ if (!out) return E_POINTER;
41
+ if (!IsEqualIID(riid, IID_IUnknown) && !IsEqualIID(riid, IID_IFileOperationProgressSink)) {
42
+ *out = nullptr;
43
+ return E_NOINTERFACE;
44
+ }
45
+ *out = static_cast<IFileOperationProgressSink *>(this);
46
+ AddRef();
47
+ return S_OK;
48
+ }
49
+ HRESULT STDMETHODCALLTYPE PreDeleteItem(DWORD flags, IShellItem *) override { return flags & TSF_DELETE_RECYCLE_IF_POSSIBLE ? S_OK : E_ABORT; }
50
+ HRESULT STDMETHODCALLTYPE StartOperations() override { return S_OK; }
51
+ HRESULT STDMETHODCALLTYPE FinishOperations(HRESULT) override { return S_OK; }
52
+ HRESULT STDMETHODCALLTYPE PreRenameItem(DWORD, IShellItem *, LPCWSTR) override { return S_OK; }
53
+ HRESULT STDMETHODCALLTYPE PostRenameItem(DWORD, IShellItem *, LPCWSTR, HRESULT, IShellItem *) override { return S_OK; }
54
+ HRESULT STDMETHODCALLTYPE PreMoveItem(DWORD, IShellItem *, IShellItem *, LPCWSTR) override { return S_OK; }
55
+ HRESULT STDMETHODCALLTYPE PostMoveItem(DWORD, IShellItem *, IShellItem *, LPCWSTR, HRESULT, IShellItem *) override { return S_OK; }
56
+ HRESULT STDMETHODCALLTYPE PreCopyItem(DWORD, IShellItem *, IShellItem *, LPCWSTR) override { return S_OK; }
57
+ HRESULT STDMETHODCALLTYPE PostCopyItem(DWORD, IShellItem *, IShellItem *, LPCWSTR, HRESULT, IShellItem *) override { return S_OK; }
58
+ // With no UI, an item that stays, as a file another program holds, fails here alone.
59
+ HRESULT STDMETHODCALLTYPE PostDeleteItem(DWORD, IShellItem *, HRESULT result, IShellItem *) override {
60
+ if (FAILED(result)) failure = result;
61
+ return S_OK;
62
+ }
63
+ HRESULT STDMETHODCALLTYPE PreNewItem(DWORD, IShellItem *, LPCWSTR) override { return S_OK; }
64
+ HRESULT STDMETHODCALLTYPE PostNewItem(DWORD, IShellItem *, LPCWSTR, LPCWSTR, DWORD, HRESULT, IShellItem *) override { return S_OK; }
65
+ HRESULT STDMETHODCALLTYPE UpdateProgress(UINT, UINT) override { return S_OK; }
66
+ HRESULT STDMETHODCALLTYPE ResetTimer() override { return S_OK; }
67
+ HRESULT STDMETHODCALLTYPE PauseTimer() override { return S_OK; }
68
+ HRESULT STDMETHODCALLTYPE ResumeTimer() override { return S_OK; }
69
+
70
+ HRESULT failure = S_OK;
71
+
72
+ private:
73
+ std::atomic<ULONG> refs_{1};
74
+ };
75
+
76
+ } // namespace
77
+
78
+ // The address goes into the command line of its scheme's handler, which the checked address has no space or quote to
79
+ // split.
80
+ void openUrl(std::string url, Done done) {
81
+ onMainThread([url = std::move(url), done = std::move(done)] {
82
+ const auto opened = reinterpret_cast<INT_PTR>(ShellExecuteW(nullptr, L"open", wide(url).c_str(), nullptr, nullptr, SW_SHOWNORMAL));
83
+ done(opened > 32 ? Failure() : Failure("crossbind: nothing opens " + url));
84
+ });
85
+ }
86
+
87
+ void openFile(std::string path, Done done) {
88
+ onMainThread([path = std::move(path), done = std::move(done)] {
89
+ if (!exists(path)) {
90
+ done("crossbind: there is no " + path);
91
+ return;
92
+ }
93
+ const auto opened = reinterpret_cast<INT_PTR>(ShellExecuteW(nullptr, L"open", wide(path).c_str(), nullptr, nullptr, SW_SHOWNORMAL));
94
+ done(opened > 32 ? Failure() : Failure("crossbind: nothing opens " + path));
95
+ });
96
+ }
97
+
98
+ void revealFile(std::string path, Done done) {
99
+ onMainThread([path = std::move(path), done = std::move(done)] {
100
+ // The shell reads a path with backslashes alone.
101
+ std::wstring native = wide(path);
102
+ std::replace(native.begin(), native.end(), L'/', L'\\');
103
+ PIDLIST_ABSOLUTE item = exists(path) ? ILCreateFromPathW(native.c_str()) : nullptr;
104
+ if (!item) {
105
+ done("crossbind: there is no " + path);
106
+ return;
107
+ }
108
+ const HRESULT shown = SHOpenFolderAndSelectItems(item, 0, nullptr, 0);
109
+ ILFree(item);
110
+ done(SUCCEEDED(shown) ? Failure() : Failure("crossbind: Explorer cannot show " + path));
111
+ });
112
+ }
113
+
114
+ void trashFile(std::string path, Done done) {
115
+ onMainThread([path = std::move(path), done = std::move(done)] {
116
+ if (!exists(path)) {
117
+ done("crossbind: there is no " + path);
118
+ return;
119
+ }
120
+ // The shell reads a path with backslashes alone.
121
+ std::wstring native = wide(path);
122
+ std::replace(native.begin(), native.end(), L'/', L'\\');
123
+ IFileOperation *operation = nullptr;
124
+ IShellItem *item = nullptr;
125
+ auto *sink = new RecycleOnly();
126
+ DWORD cookie = 0;
127
+ BOOL isAborted = FALSE;
128
+ HRESULT result = CoCreateInstance(CLSID_FileOperation, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&operation));
129
+ if (SUCCEEDED(result)) result = operation->SetOperationFlags(FOF_NO_UI | FOFX_RECYCLEONDELETE);
130
+ if (SUCCEEDED(result)) result = SHCreateItemFromParsingName(native.c_str(), nullptr, IID_PPV_ARGS(&item));
131
+ if (SUCCEEDED(result)) result = operation->Advise(sink, &cookie);
132
+ if (SUCCEEDED(result)) result = operation->DeleteItem(item, nullptr);
133
+ if (SUCCEEDED(result)) result = operation->PerformOperations();
134
+ if (SUCCEEDED(result)) result = sink->failure;
135
+ if (operation) operation->GetAnyOperationsAborted(&isAborted);
136
+ if (cookie) operation->Unadvise(cookie);
137
+ if (item) item->Release();
138
+ if (operation) operation->Release();
139
+ sink->Release();
140
+ if (isAborted) {
141
+ done("crossbind: cannot move " + path + " to the Recycle Bin, which its drive has none of");
142
+ } else if (FAILED(result)) {
143
+ char code[sizeof "0x00000000"];
144
+ std::snprintf(code, sizeof code, "0x%08lx", static_cast<unsigned long>(result));
145
+ done("crossbind: cannot move " + path + " to the Recycle Bin: " + code);
146
+ } else {
147
+ done(std::nullopt);
148
+ }
149
+ });
150
+ }
151
+
152
+ } // namespace crossbind::shell
package/package.json CHANGED
@@ -1,7 +1,41 @@
1
1
  {
2
- "name": "@crossbind/extension-shell",
3
- "version": "0.0.0-bootstrap.0",
4
- "description": "Placeholder that let npm trust the crossbind release workflow; install @crossbind/extension-shell@beta.",
5
- "license": "MIT",
6
- "repository": "https://github.com/crossbind/crossbind.git"
7
- }
2
+ "name": "@crossbind/extension-shell",
3
+ "version": "2.0.0-beta.69",
4
+ "description": "crossbind app extension: opens addresses and files outside the app, shows them in the file manager, moves them to the trash.",
5
+ "homepage": "https://github.com/crossbind/crossbind/tree/main/extensions/shell#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
+ "default": "./page.js"
16
+ },
17
+ "./crossbind.extension.js": "./crossbind.extension.js"
18
+ },
19
+ "files": [
20
+ "crossbind.extension.js",
21
+ "native",
22
+ "page.d.ts",
23
+ "page.js"
24
+ ],
25
+ "crossbind": {
26
+ "extension": "./crossbind.extension.js"
27
+ },
28
+ "peerDependencies": {
29
+ "crossbind": "^2.0.0-beta.69"
30
+ },
31
+ "devDependencies": {
32
+ "crossbind": "^2.0.0-beta.69"
33
+ },
34
+ "keywords": [
35
+ "crossbind-extension",
36
+ "crossbind",
37
+ "system",
38
+ "shell",
39
+ "app"
40
+ ]
41
+ }
package/page.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export declare const shell: {
2
+ /** An http, https or mailto address in the default browser or mail app. */
3
+ openExternal(url: string | URL): Promise<void>;
4
+ /** A file in the app the system opens its kind with, where app.openPath in crossbind.config.js lets the page: nowhere without it. */
5
+ openPath(path: string): Promise<void>;
6
+ /** A file, selected, in the system's file manager: one in the app's folders, or one a dialog picked during this run. */
7
+ revealInFolder(path: string): Promise<void>;
8
+ /** A file or folder moved to the system's trash, where its user can take it back: one in the app's folders, not one of them itself, or one a dialog picked during this run. Where the system has no trash, nothing moves and it rejects. */
9
+ trash(path: string): Promise<void>;
10
+ };
package/page.js ADDED
@@ -0,0 +1,24 @@
1
+ import { invoke } from 'crossbind/app/core';
2
+
3
+ // Opening an address or a file outside the app, as a page calls it.
4
+ function checkedPath(name, path) {
5
+ if (typeof path !== 'string' || !path) throw new TypeError(`crossbind: ${name} takes a path.`);
6
+ return { path };
7
+ }
8
+
9
+ export const shell = {
10
+ // An http, https or mailto address in the default browser or mail app.
11
+ openExternal: async (url) => {
12
+ if (typeof url !== 'string' && !(url instanceof URL)) throw new TypeError('crossbind: shell.openExternal takes an address.');
13
+ return invoke('shell.openExternal', { url: String(url) });
14
+ },
15
+ // A file in the app the system opens its kind with. Opening a program runs it, and a script in the page could ask for
16
+ // any path, so the host opens files where app.openPath in crossbind.config.js lets it alone; native code opens any.
17
+ openPath: async (path) => invoke('shell.openPath', checkedPath('shell.openPath', path)),
18
+ // A file, selected, in the system's file manager: one in the app's folders, or one a dialog picked during this run.
19
+ revealInFolder: async (path) => invoke('shell.revealInFolder', checkedPath('shell.revealInFolder', path)),
20
+ // A file or folder moved to the system's trash, where its user can take it back: one in the app's folders, not one of
21
+ // them itself, or one a dialog picked during this run. Where the system has no trash, nothing moves.
22
+ trash: async (path) => invoke('shell.trash', checkedPath('shell.trash', path)),
23
+ };
24
+