code2app 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +111 -0
  2. package/assets/icon-source.png +0 -0
  3. package/bin/cli.js +248 -0
  4. package/dist/index.html +24 -0
  5. package/docs/APP_STORES.md +129 -0
  6. package/docs/BUILDING.md +134 -0
  7. package/docs/LOCAL_APPS.md +116 -0
  8. package/docs/MOBILE.md +78 -0
  9. package/docs/OAUTH.md +76 -0
  10. package/package.json +66 -0
  11. package/profiles/README.md +56 -0
  12. package/profiles/example.json +37 -0
  13. package/scripts/build-sidecar.mjs +80 -0
  14. package/scripts/configure.mjs +222 -0
  15. package/scripts/generate-icons.mjs +53 -0
  16. package/scripts/generate-placeholder-icons.mjs +186 -0
  17. package/scripts/lib/png.mjs +141 -0
  18. package/scripts/profile.mjs +147 -0
  19. package/src-tauri/Cargo.lock +5832 -0
  20. package/src-tauri/Cargo.toml +47 -0
  21. package/src-tauri/build.rs +3 -0
  22. package/src-tauri/capabilities/default.json +15 -0
  23. package/src-tauri/capabilities/remote.json +17 -0
  24. package/src-tauri/icons/128x128.png +0 -0
  25. package/src-tauri/icons/128x128@2x.png +0 -0
  26. package/src-tauri/icons/32x32.png +0 -0
  27. package/src-tauri/icons/64x64.png +0 -0
  28. package/src-tauri/icons/Square107x107Logo.png +0 -0
  29. package/src-tauri/icons/Square142x142Logo.png +0 -0
  30. package/src-tauri/icons/Square150x150Logo.png +0 -0
  31. package/src-tauri/icons/Square284x284Logo.png +0 -0
  32. package/src-tauri/icons/Square30x30Logo.png +0 -0
  33. package/src-tauri/icons/Square310x310Logo.png +0 -0
  34. package/src-tauri/icons/Square44x44Logo.png +0 -0
  35. package/src-tauri/icons/Square71x71Logo.png +0 -0
  36. package/src-tauri/icons/Square89x89Logo.png +0 -0
  37. package/src-tauri/icons/StoreLogo.png +0 -0
  38. package/src-tauri/icons/icon.icns +0 -0
  39. package/src-tauri/icons/icon.ico +0 -0
  40. package/src-tauri/icons/icon.png +0 -0
  41. package/src-tauri/src/generated_config.rs +22 -0
  42. package/src-tauri/src/lib.rs +212 -0
  43. package/src-tauri/src/main.rs +6 -0
  44. package/src-tauri/tauri.conf.json +84 -0
  45. package/vitest.config.mjs +35 -0
@@ -0,0 +1,47 @@
1
+ [package]
2
+ name = "native-app-wrapper"
3
+ version = "0.1.0"
4
+ description = "Tauri wrapper that packages a website or a bundled CLI as a native app"
5
+ authors = ["OpenSourceAGI"]
6
+ edition = "2021"
7
+ rust-version = "1.77.2"
8
+
9
+ [lib]
10
+ name = "native_app_wrapper_lib"
11
+ crate-type = ["staticlib", "cdylib", "rlib"]
12
+
13
+ [build-dependencies]
14
+ tauri-build = { version = "2", features = [] }
15
+
16
+ [dependencies]
17
+ tauri = { version = "2", features = [] }
18
+ tauri-plugin-opener = "2"
19
+ tauri-plugin-deep-link = "2"
20
+ # Backs the `sidecar_output` command: local-mode apps bundle a CLI next to the
21
+ # binary and read its stdout instead of reimplementing it in Rust.
22
+ tauri-plugin-shell = "2"
23
+ serde = { version = "1", features = ["derive"] }
24
+ serde_json = "1"
25
+ url = "2"
26
+ urlencoding = "2"
27
+
28
+ # Desktop-only plugins: neither single-instance (used to route a second
29
+ # deep-link launch's argv into the already-running app) nor the updater apply
30
+ # to the mobile targets, which have their own OS-level equivalents (Android/
31
+ # iOS both already dedupe app launches, and app-store updates replace the
32
+ # updater's job on mobile).
33
+ [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
34
+ tauri-plugin-single-instance = "2"
35
+ tauri-plugin-updater = "2"
36
+ # Backs the fullscreen toggle: a profile can open the window in true OS
37
+ # fullscreen, which has no title bar to click a restore button on, so an
38
+ # OS-level shortcut is the only reliable way out regardless of what the wrapped
39
+ # page's own JS does.
40
+ tauri-plugin-global-shortcut = "2"
41
+
42
+ [profile.release]
43
+ panic = "abort"
44
+ codegen-units = 1
45
+ lto = true
46
+ opt-level = "s"
47
+ strip = true
@@ -0,0 +1,3 @@
1
+ fn main() {
2
+ tauri_build::build()
3
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "../gen/schemas/desktop-schema.json",
3
+ "identifier": "default",
4
+ "description": "Baseline permissions for the wrapper's own bundled window content and Rust side. Capabilities with no `remote` block apply only to locally bundled content, so nothing here is reachable from a wrapped website — that gets the separate, narrower `remote` capability.",
5
+ "windows": ["main"],
6
+ "permissions": [
7
+ "core:default",
8
+ "core:event:default",
9
+ "core:window:allow-set-focus",
10
+ "core:window:allow-unminimize",
11
+ "opener:default",
12
+ "opener:allow-open-url",
13
+ "deep-link:default"
14
+ ]
15
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "../gen/schemas/remote-schema.json",
3
+ "identifier": "remote",
4
+ "description": "Scoped IPC access for the wrapped site itself (the profile's trustedOrigins), so its login page can ask the wrapper to open the system browser for OAuth. Nothing else is exposed to remote content.",
5
+ "windows": [
6
+ "main"
7
+ ],
8
+ "remote": {
9
+ "urls": [
10
+ "https://example.com/*",
11
+ "https://*.example.com/*"
12
+ ]
13
+ },
14
+ "permissions": [
15
+ "opener:allow-open-url"
16
+ ]
17
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,22 @@
1
+ // Generated by scripts/configure.mjs from profiles/example.json. Do not edit by hand.
2
+
3
+ /// Custom URL scheme registered with the OS for the OAuth handoff, or ""
4
+ /// when the profile doesn't use one.
5
+ pub const DEEP_LINK_SCHEME: &str = "exampleapp";
6
+
7
+ /// Origin the main window loads in remote mode, or "" in local mode.
8
+ pub const APP_URL: &str = "https://example.com";
9
+
10
+ /// Name of the bundled CLI exposed through the `sidecar_output` command,
11
+ /// or "" when the profile bundles none.
12
+ pub const SIDECAR_NAME: &str = "";
13
+
14
+ /// The fixed argument list that sidecar is always invoked with. Fixed, and
15
+ /// not passed in from the frontend, so the webview can't turn the bundled
16
+ /// binary into an arbitrary-argument process spawner.
17
+ pub const SIDECAR_ARGS: &[&str] = &[];
18
+
19
+ /// Whether the profile configured auto-updates. False means the updater
20
+ /// plugin is never registered — registering it without a `pubkey` in
21
+ /// tauri.conf.json panics at startup rather than staying inert.
22
+ pub const UPDATER_ENABLED: bool = false;
@@ -0,0 +1,212 @@
1
+ // Generic Tauri wrapper. It packages one of two things as a native app,
2
+ // depending on the active profile's `mode` (see ../profiles/README.md):
3
+ //
4
+ // remote — the main window loads `generated_config::APP_URL` directly, so the
5
+ // live site is the app's entire UI. The only thing the wrapper adds
6
+ // beyond the window is an OAuth handoff: providers like Google
7
+ // refuse to run their login flow inside an embedded webview, so
8
+ // sign-in happens in the system browser and comes back through the
9
+ // `<scheme>://auth-callback?token=...` deep link handled below.
10
+ // See ../docs/OAUTH.md for the full round trip.
11
+ //
12
+ // local — the main window loads the frontend bundled in ../dist, and the
13
+ // app's data comes from a CLI bundled alongside it as a Tauri
14
+ // sidecar, reachable through the `sidecar_output` command. This is
15
+ // how a command-line tool ships as a GUI app with no runtime (no
16
+ // Node, no interpreter) installed on the user's machine.
17
+ // See ../docs/LOCAL_APPS.md.
18
+ //
19
+ // Everything that differs between apps is generated into generated_config.rs by
20
+ // scripts/configure.mjs, so this file never needs editing to package a
21
+ // different app.
22
+
23
+ mod generated_config;
24
+
25
+ use tauri::{Manager, Url};
26
+ use tauri_plugin_deep_link::DeepLinkExt;
27
+
28
+ #[cfg_attr(mobile, tauri::mobile_entry_point)]
29
+ pub fn run() {
30
+ let mut builder = tauri::Builder::default();
31
+
32
+ // Neither single-instance nor the updater apply on Android/iOS: the OS
33
+ // already dedupes app launches there, and store updates replace what
34
+ // tauri-plugin-updater does on desktop.
35
+ #[cfg(not(any(target_os = "android", target_os = "ios")))]
36
+ {
37
+ builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
38
+ // On Windows/Linux, clicking a deep link while the app is already
39
+ // running launches a *second* process instead of firing the
40
+ // `on_open_url` event in the first one — single-instance forwards
41
+ // that second launch's argv here instead, so the callback still
42
+ // needs to be pulled out of argv and handled the same way.
43
+ if !generated_config::DEEP_LINK_SCHEME.is_empty() {
44
+ let scheme_prefix = format!("{}://", generated_config::DEEP_LINK_SCHEME);
45
+ if let Some(url) = argv.iter().find(|arg| arg.starts_with(&scheme_prefix)) {
46
+ handle_deep_link(app, url);
47
+ }
48
+ }
49
+ if let Some(window) = app.get_webview_window("main") {
50
+ let _ = window.set_focus();
51
+ let _ = window.unminimize();
52
+ }
53
+ }));
54
+ // Only when the profile configured updates. tauri-plugin-updater reads
55
+ // `plugins.updater.pubkey` during initialization and *panics* if it
56
+ // isn't there, so registering it unconditionally would crash every app
57
+ // that hasn't set up an update endpoint — which is the default.
58
+ if generated_config::UPDATER_ENABLED {
59
+ builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
60
+ }
61
+ builder = builder.plugin(fullscreen_toggle_plugin());
62
+ }
63
+
64
+ builder
65
+ .plugin(tauri_plugin_opener::init())
66
+ .plugin(tauri_plugin_shell::init())
67
+ .plugin(tauri_plugin_deep_link::init())
68
+ .invoke_handler(tauri::generate_handler![sidecar_output])
69
+ .setup(|app| {
70
+ if !generated_config::DEEP_LINK_SCHEME.is_empty() {
71
+ let handle = app.handle().clone();
72
+ app.deep_link().on_open_url(move |event| {
73
+ for url in event.urls() {
74
+ handle_deep_link(&handle, url.as_str());
75
+ }
76
+ });
77
+
78
+ // Desktop platforms must register the scheme with the OS at
79
+ // runtime (the tauri.conf.json `plugins.deep-link.desktop.schemes`
80
+ // entry declares it for the installer; this call is what actually
81
+ // wires it up in dev / for unpackaged runs). Android/iOS pick the
82
+ // scheme up from the generated project's manifest/Info.plist
83
+ // instead, produced by `tauri android init` / `tauri ios init`
84
+ // reading the same tauri.conf.json — calling register_all() there
85
+ // is a no-op.
86
+ #[cfg(not(any(target_os = "android", target_os = "ios")))]
87
+ {
88
+ let _ = app.deep_link().register_all();
89
+ }
90
+ }
91
+
92
+ Ok(())
93
+ })
94
+ .run(tauri::generate_context!())
95
+ .expect("error while running the native app wrapper");
96
+ }
97
+
98
+ /// Runs the bundled sidecar CLI and returns its stdout, for local-mode apps
99
+ /// whose frontend is a view over a command-line tool.
100
+ ///
101
+ /// Two things are deliberately not parameters.
102
+ ///
103
+ /// The argument list is fixed at `generated_config::SIDECAR_ARGS`, so a page can
104
+ /// ask for the snapshot the app is built around but can't turn the bundled
105
+ /// binary into a general-purpose process spawner. An app that needs several
106
+ /// invocations should add several commands with several fixed argument lists,
107
+ /// not open this one up.
108
+ ///
109
+ /// And the caller is checked to be locally bundled content. Tauri already
110
+ /// rejects app-defined commands coming from a remote origin unless a capability
111
+ /// explicitly grants them, so this is a second line of defense rather than the
112
+ /// only one — but it is the line that doesn't depend on nobody ever adding this
113
+ /// command to `capabilities/remote.json`, which is exactly the mistake that
114
+ /// would hand a wrapped website a process spawner.
115
+ #[tauri::command]
116
+ async fn sidecar_output(window: tauri::WebviewWindow) -> Result<String, String> {
117
+ use tauri_plugin_shell::ShellExt;
118
+
119
+ if generated_config::SIDECAR_NAME.is_empty() {
120
+ return Err("this app bundles no sidecar (the profile has no \"sidecar\" block)".into());
121
+ }
122
+
123
+ // Tauri serves bundled content from tauri://localhost, or
124
+ // http://tauri.localhost on Windows. Anything else is a real website.
125
+ let url = window.url().map_err(|e| e.to_string())?;
126
+ let is_bundled_content =
127
+ url.scheme() == "tauri" || url.host_str() == Some("tauri.localhost");
128
+ if !is_bundled_content {
129
+ return Err("sidecar_output is only callable from the app's bundled frontend".into());
130
+ }
131
+
132
+ let output = window
133
+ .app_handle()
134
+ .shell()
135
+ .sidecar(generated_config::SIDECAR_NAME)
136
+ .map_err(|e| format!("sidecar \"{}\" is not bundled: {e}", generated_config::SIDECAR_NAME))?
137
+ .args(generated_config::SIDECAR_ARGS)
138
+ .output()
139
+ .await
140
+ .map_err(|e| format!("sidecar failed to run: {e}"))?;
141
+
142
+ if !output.status.success() {
143
+ return Err(format!(
144
+ "sidecar exited with {}: {}",
145
+ output.status.code().unwrap_or(-1),
146
+ String::from_utf8_lossy(&output.stderr).trim(),
147
+ ));
148
+ }
149
+
150
+ Ok(String::from_utf8_lossy(&output.stdout).into_owned())
151
+ }
152
+
153
+ /// F11 / Ctrl+Shift+F toggles the main window's fullscreen state. A profile can
154
+ /// open the window in true OS fullscreen (`window.fullscreen`), which on most
155
+ /// platforms hides the title bar entirely, so this is the only way out that
156
+ /// doesn't depend on the wrapped page's own JS or a click target that isn't
157
+ /// there.
158
+ #[cfg(not(any(target_os = "android", target_os = "ios")))]
159
+ fn fullscreen_toggle_plugin<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
160
+ use tauri_plugin_global_shortcut::ShortcutState;
161
+
162
+ tauri_plugin_global_shortcut::Builder::new()
163
+ .with_shortcuts(["F11", "CmdOrCtrl+Shift+F"])
164
+ .expect("fullscreen shortcuts are valid accelerator strings")
165
+ .with_handler(|app, _shortcut, event| {
166
+ if event.state() != ShortcutState::Pressed {
167
+ return;
168
+ }
169
+ if let Some(window) = app.get_webview_window("main") {
170
+ let is_fullscreen = window.is_fullscreen().unwrap_or(false);
171
+ let _ = window.set_fullscreen(!is_fullscreen);
172
+ }
173
+ })
174
+ .build()
175
+ }
176
+
177
+ /// Handles `<scheme>://auth-callback?token=...`. The token is a one-time login
178
+ /// token the site minted from the session the system browser just established
179
+ /// with the OAuth provider. Verifying it is a same-origin POST, not something a
180
+ /// plain navigation can hit — so instead of calling that endpoint from Rust,
181
+ /// this navigates the window to `/auth/native-callback` (same origin as the
182
+ /// site already loaded here), which spends the token client-side. That POST
183
+ /// happens same-origin to this webview, so the resulting session cookie lands
184
+ /// in *this* webview's cookie jar — the whole reason the token exists, since
185
+ /// the system browser's cookies and this window's cookies are different jars
186
+ /// that can't otherwise see each other's session. See ../docs/OAUTH.md.
187
+ fn handle_deep_link(app: &tauri::AppHandle, raw_url: &str) {
188
+ let Ok(parsed) = Url::parse(raw_url) else {
189
+ return;
190
+ };
191
+ let is_auth_callback = parsed.host_str() == Some("auth-callback")
192
+ || parsed.path().trim_start_matches('/') == "auth-callback";
193
+ if !is_auth_callback {
194
+ return;
195
+ }
196
+ let Some((_, token)) = parsed.query_pairs().find(|(key, _)| key == "token") else {
197
+ return;
198
+ };
199
+
200
+ let Some(window) = app.get_webview_window("main") else {
201
+ return;
202
+ };
203
+ let callback_url = format!(
204
+ "{}/auth/native-callback?token={}",
205
+ generated_config::APP_URL,
206
+ urlencoding::encode(&token),
207
+ );
208
+ if let Ok(url) = Url::parse(&callback_url) {
209
+ let _ = window.navigate(url);
210
+ let _ = window.set_focus();
211
+ }
212
+ }
@@ -0,0 +1,6 @@
1
+ // Prevents an additional console window from opening on Windows in release builds.
2
+ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
3
+
4
+ fn main() {
5
+ native_app_wrapper_lib::run();
6
+ }
@@ -0,0 +1,84 @@
1
+ {
2
+ "$schema": "https://schema.tauri.app/config/2",
3
+ "productName": "Example App",
4
+ "version": "0.1.0",
5
+ "identifier": "com.example.app",
6
+ "build": {
7
+ "frontendDist": "../dist"
8
+ },
9
+ "app": {
10
+ "withGlobalTauri": true,
11
+ "windows": [
12
+ {
13
+ "label": "main",
14
+ "title": "Example App",
15
+ "url": "https://example.com",
16
+ "width": 1280,
17
+ "height": 800,
18
+ "minWidth": 480,
19
+ "minHeight": 480,
20
+ "resizable": true,
21
+ "fullscreen": false,
22
+ "center": true
23
+ }
24
+ ],
25
+ "security": {
26
+ "capabilities": [
27
+ "default",
28
+ "remote"
29
+ ]
30
+ }
31
+ },
32
+ "bundle": {
33
+ "active": true,
34
+ "targets": "all",
35
+ "publisher": "Example, Inc.",
36
+ "copyright": "Example, Inc.",
37
+ "category": "Productivity",
38
+ "shortDescription": "One-line description shown in store listings.",
39
+ "longDescription": "One-line description shown in store listings.",
40
+ "icon": [
41
+ "icons/32x32.png",
42
+ "icons/128x128.png",
43
+ "icons/128x128@2x.png",
44
+ "icons/icon.icns",
45
+ "icons/icon.ico"
46
+ ],
47
+ "windows": {
48
+ "nsis": {
49
+ "installMode": "currentUser"
50
+ }
51
+ },
52
+ "macOS": {
53
+ "minimumSystemVersion": "10.15"
54
+ },
55
+ "linux": {
56
+ "deb": {
57
+ "depends": []
58
+ }
59
+ },
60
+ "android": {
61
+ "minSdkVersion": 24
62
+ },
63
+ "iOS": {
64
+ "minimumSystemVersion": "14.0"
65
+ }
66
+ },
67
+ "plugins": {
68
+ "deep-link": {
69
+ "desktop": {
70
+ "schemes": [
71
+ "exampleapp"
72
+ ]
73
+ },
74
+ "mobile": [
75
+ {
76
+ "scheme": [
77
+ "exampleapp"
78
+ ],
79
+ "appLink": false
80
+ }
81
+ ]
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,35 @@
1
+ /// <reference types="vitest/config" />
2
+ import { defineConfig } from "vitest/config";
3
+
4
+ // Per-package Vitest setup. Coverage is written to this package's own
5
+ // ./coverage/lcov.info so Codecov can flag it under "native-app-wrapper".
6
+ export default defineConfig({
7
+ test: {
8
+ environment: "node",
9
+ include: ["{test,tests,src}/**/*.{test,spec}.{js,mjs,ts,tsx}"],
10
+ exclude: ["**/node_modules/**", "**/dist/**"],
11
+ // Infrastructure is in place before every package has a suite.
12
+ passWithNoTests: true,
13
+ coverage: {
14
+ provider: "v8",
15
+ reporter: ["text", "json", "lcov"],
16
+ reportsDirectory: "./coverage",
17
+ reportOnFailure: true,
18
+ include: [
19
+ "bin/**/*.{js,mjs}",
20
+ "scripts/**/*.{js,mjs}",
21
+ ],
22
+ exclude: [
23
+ "**/node_modules/**",
24
+ "**/dist/**",
25
+ "**/build/**",
26
+ "**/coverage/**",
27
+ "**/demo/**",
28
+ "**/*.d.ts",
29
+ "**/*.config.*",
30
+ "**/*.{test,spec}.*",
31
+ "**/*.template.*",
32
+ ],
33
+ },
34
+ },
35
+ });