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.
- package/README.md +111 -0
- package/assets/icon-source.png +0 -0
- package/bin/cli.js +248 -0
- package/dist/index.html +24 -0
- package/docs/APP_STORES.md +129 -0
- package/docs/BUILDING.md +134 -0
- package/docs/LOCAL_APPS.md +116 -0
- package/docs/MOBILE.md +78 -0
- package/docs/OAUTH.md +76 -0
- package/package.json +66 -0
- package/profiles/README.md +56 -0
- package/profiles/example.json +37 -0
- package/scripts/build-sidecar.mjs +80 -0
- package/scripts/configure.mjs +222 -0
- package/scripts/generate-icons.mjs +53 -0
- package/scripts/generate-placeholder-icons.mjs +186 -0
- package/scripts/lib/png.mjs +141 -0
- package/scripts/profile.mjs +147 -0
- package/src-tauri/Cargo.lock +5832 -0
- package/src-tauri/Cargo.toml +47 -0
- package/src-tauri/build.rs +3 -0
- package/src-tauri/capabilities/default.json +15 -0
- package/src-tauri/capabilities/remote.json +17 -0
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/64x64.png +0 -0
- package/src-tauri/icons/Square107x107Logo.png +0 -0
- package/src-tauri/icons/Square142x142Logo.png +0 -0
- package/src-tauri/icons/Square150x150Logo.png +0 -0
- package/src-tauri/icons/Square284x284Logo.png +0 -0
- package/src-tauri/icons/Square30x30Logo.png +0 -0
- package/src-tauri/icons/Square310x310Logo.png +0 -0
- package/src-tauri/icons/Square44x44Logo.png +0 -0
- package/src-tauri/icons/Square71x71Logo.png +0 -0
- package/src-tauri/icons/Square89x89Logo.png +0 -0
- package/src-tauri/icons/StoreLogo.png +0 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/icons/icon.png +0 -0
- package/src-tauri/src/generated_config.rs +22 -0
- package/src-tauri/src/lib.rs +212 -0
- package/src-tauri/src/main.rs +6 -0
- package/src-tauri/tauri.conf.json +84 -0
- package/vitest.config.mjs +35 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Regenerates everything that encodes an app's identity — src-tauri/tauri.conf.json,
|
|
3
|
+
// the Rust-side constants it implies, and the remote capability's origin scope —
|
|
4
|
+
// from a single JSON profile in profiles/. That indirection is what makes this
|
|
5
|
+
// package generic: swap the profile, re-run this script, and the wrapper is a
|
|
6
|
+
// different app. Nothing downstream (icons, CI, the OAuth handoff, the sidecar
|
|
7
|
+
// bridge) reads the profile directly, so there is exactly one place that
|
|
8
|
+
// understands the profile -> Tauri config mapping.
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { loadProfile, resolveProfileName } from "./profile.mjs";
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
export function configure(rootDir, profileName) {
|
|
18
|
+
const profile = loadProfile(rootDir, profileName);
|
|
19
|
+
const win = profile.window;
|
|
20
|
+
|
|
21
|
+
const tauriConf = {
|
|
22
|
+
$schema: "https://schema.tauri.app/config/2",
|
|
23
|
+
productName: profile.productName,
|
|
24
|
+
version: profile.version,
|
|
25
|
+
identifier: profile.identifier,
|
|
26
|
+
build: {
|
|
27
|
+
frontendDist: "../dist",
|
|
28
|
+
},
|
|
29
|
+
app: {
|
|
30
|
+
withGlobalTauri: true,
|
|
31
|
+
windows: [
|
|
32
|
+
{
|
|
33
|
+
label: "main",
|
|
34
|
+
title: win.title,
|
|
35
|
+
// Remote mode points the window straight at the live site; local mode
|
|
36
|
+
// leaves it on the bundled dist/ frontend Tauri serves itself.
|
|
37
|
+
url: profile.mode === "remote" ? profile.url : "index.html",
|
|
38
|
+
width: win.width,
|
|
39
|
+
height: win.height,
|
|
40
|
+
minWidth: win.minWidth,
|
|
41
|
+
minHeight: win.minHeight,
|
|
42
|
+
resizable: win.resizable,
|
|
43
|
+
fullscreen: win.fullscreen,
|
|
44
|
+
center: true,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
security: {
|
|
48
|
+
// A local-mode app has no remote content to scope, so it doesn't load
|
|
49
|
+
// the remote capability at all (configure removes the file too).
|
|
50
|
+
capabilities: profile.trustedOrigins.length > 0 ? ["default", "remote"] : ["default"],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
bundle: {
|
|
54
|
+
active: true,
|
|
55
|
+
targets: "all",
|
|
56
|
+
publisher: profile.publisher ?? profile.copyright ?? profile.appName,
|
|
57
|
+
copyright: profile.copyright ?? "",
|
|
58
|
+
category: profile.category ?? "Productivity",
|
|
59
|
+
shortDescription: profile.shortDescription ?? "",
|
|
60
|
+
longDescription: profile.longDescription ?? profile.shortDescription ?? "",
|
|
61
|
+
icon: [
|
|
62
|
+
"icons/32x32.png",
|
|
63
|
+
"icons/128x128.png",
|
|
64
|
+
"icons/128x128@2x.png",
|
|
65
|
+
"icons/icon.icns",
|
|
66
|
+
"icons/icon.ico",
|
|
67
|
+
],
|
|
68
|
+
windows: {
|
|
69
|
+
nsis: {
|
|
70
|
+
installMode: "currentUser",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
macOS: {
|
|
74
|
+
minimumSystemVersion: profile.macos?.minimumSystemVersion ?? "10.15",
|
|
75
|
+
},
|
|
76
|
+
linux: {
|
|
77
|
+
deb: { depends: [] },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
plugins: {},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// The updater plugin fails *initialization* without a `pubkey` — an
|
|
84
|
+
// `{ "active": false }` block is not an off switch, it's a crash on launch.
|
|
85
|
+
// So a profile that hasn't configured updates gets no updater config and no
|
|
86
|
+
// registered plugin at all (see UPDATER_ENABLED below).
|
|
87
|
+
if (profile.updater) {
|
|
88
|
+
tauriConf.plugins.updater = {
|
|
89
|
+
active: true,
|
|
90
|
+
pubkey: profile.updater.pubkey,
|
|
91
|
+
endpoints: profile.updater.endpoints,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (profile.deepLinkScheme) {
|
|
96
|
+
tauriConf.plugins["deep-link"] = {
|
|
97
|
+
desktop: {
|
|
98
|
+
schemes: [profile.deepLinkScheme],
|
|
99
|
+
},
|
|
100
|
+
// Plain custom-scheme deep link (`<scheme>://...`), not a verified
|
|
101
|
+
// Android App Link / iOS Universal Link — the latter needs the site to
|
|
102
|
+
// host .well-known/assetlinks.json + apple-app-site-association, which is
|
|
103
|
+
// unnecessary complexity for a login handoff scheme that's never shown to
|
|
104
|
+
// the user as a clickable web link.
|
|
105
|
+
mobile: [
|
|
106
|
+
{
|
|
107
|
+
scheme: [profile.deepLinkScheme],
|
|
108
|
+
appLink: false,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (profile.sidecar) {
|
|
115
|
+
// Tauri resolves this to binaries/<name>-<target triple> at bundle time, so
|
|
116
|
+
// the build fails loudly if scripts/build-sidecar.mjs hasn't produced one
|
|
117
|
+
// for the platform being built.
|
|
118
|
+
tauriConf.bundle.externalBin = [`binaries/${profile.sidecar.name}`];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (profile.android) {
|
|
122
|
+
tauriConf.bundle.android = {
|
|
123
|
+
minSdkVersion: profile.android.minSdkVersion ?? 24,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (profile.ios) {
|
|
128
|
+
tauriConf.bundle.iOS = {
|
|
129
|
+
minimumSystemVersion: profile.ios.minimumSystemVersion ?? "14.0",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const written = [];
|
|
134
|
+
const write = (relPath, contents) => {
|
|
135
|
+
const outPath = path.join(rootDir, relPath);
|
|
136
|
+
mkdirSync(path.dirname(outPath), { recursive: true });
|
|
137
|
+
writeFileSync(outPath, contents, "utf8");
|
|
138
|
+
written.push(relPath);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
write("src-tauri/tauri.conf.json", `${JSON.stringify(tauriConf, null, 2)}\n`);
|
|
142
|
+
|
|
143
|
+
// The same values the JSON config carries also have to exist on the Rust
|
|
144
|
+
// side: the desktop single-instance argv parser matches on the deep-link
|
|
145
|
+
// scheme, the OAuth handoff rebuilds an absolute callback URL from the app
|
|
146
|
+
// URL, and the sidecar command needs the bundled binary's name. Generating
|
|
147
|
+
// them into one const file is what keeps the two from drifting apart.
|
|
148
|
+
const rustArgs = profile.sidecar
|
|
149
|
+
? `&[${profile.sidecar.args.map((a) => JSON.stringify(a)).join(", ")}]`
|
|
150
|
+
: "&[]";
|
|
151
|
+
write(
|
|
152
|
+
"src-tauri/src/generated_config.rs",
|
|
153
|
+
[
|
|
154
|
+
`// Generated by scripts/configure.mjs from profiles/${profile.name}.json. Do not edit by hand.`,
|
|
155
|
+
"",
|
|
156
|
+
"/// Custom URL scheme registered with the OS for the OAuth handoff, or \"\"",
|
|
157
|
+
"/// when the profile doesn't use one.",
|
|
158
|
+
`pub const DEEP_LINK_SCHEME: &str = ${JSON.stringify(profile.deepLinkScheme ?? "")};`,
|
|
159
|
+
"",
|
|
160
|
+
"/// Origin the main window loads in remote mode, or \"\" in local mode.",
|
|
161
|
+
`pub const APP_URL: &str = ${JSON.stringify(profile.url ?? "")};`,
|
|
162
|
+
"",
|
|
163
|
+
"/// Name of the bundled CLI exposed through the `sidecar_output` command,",
|
|
164
|
+
"/// or \"\" when the profile bundles none.",
|
|
165
|
+
`pub const SIDECAR_NAME: &str = ${JSON.stringify(profile.sidecar?.name ?? "")};`,
|
|
166
|
+
"",
|
|
167
|
+
"/// The fixed argument list that sidecar is always invoked with. Fixed, and",
|
|
168
|
+
"/// not passed in from the frontend, so the webview can't turn the bundled",
|
|
169
|
+
"/// binary into an arbitrary-argument process spawner.",
|
|
170
|
+
`pub const SIDECAR_ARGS: &[&str] = ${rustArgs};`,
|
|
171
|
+
"",
|
|
172
|
+
"/// Whether the profile configured auto-updates. False means the updater",
|
|
173
|
+
"/// plugin is never registered — registering it without a `pubkey` in",
|
|
174
|
+
"/// tauri.conf.json panics at startup rather than staying inert.",
|
|
175
|
+
`pub const UPDATER_ENABLED: bool = ${profile.updater ? "true" : "false"};`,
|
|
176
|
+
"",
|
|
177
|
+
].join("\n"),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// capabilities/remote.json grants the wrapped site itself a narrow slice of
|
|
181
|
+
// IPC. Its scope is the profile's trustedOrigins, so generating the file is
|
|
182
|
+
// the only way that list can't drift from what the profile claims.
|
|
183
|
+
const remoteCapabilityPath = path.join(rootDir, "src-tauri/capabilities/remote.json");
|
|
184
|
+
if (profile.trustedOrigins.length > 0) {
|
|
185
|
+
write(
|
|
186
|
+
"src-tauri/capabilities/remote.json",
|
|
187
|
+
`${JSON.stringify(
|
|
188
|
+
{
|
|
189
|
+
$schema: "../gen/schemas/remote-schema.json",
|
|
190
|
+
identifier: "remote",
|
|
191
|
+
description:
|
|
192
|
+
"Scoped IPC access for the wrapped site itself (the profile's trustedOrigins), so its " +
|
|
193
|
+
"login page can ask the wrapper to open the system browser for OAuth. Nothing else is " +
|
|
194
|
+
"exposed to remote content.",
|
|
195
|
+
windows: ["main"],
|
|
196
|
+
remote: {
|
|
197
|
+
urls: profile.trustedOrigins.map((origin) => `${origin.replace(/\/+$/, "")}/*`),
|
|
198
|
+
},
|
|
199
|
+
permissions: ["opener:allow-open-url"],
|
|
200
|
+
},
|
|
201
|
+
null,
|
|
202
|
+
2,
|
|
203
|
+
)}\n`,
|
|
204
|
+
);
|
|
205
|
+
} else if (existsSync(remoteCapabilityPath)) {
|
|
206
|
+
rmSync(remoteCapabilityPath);
|
|
207
|
+
written.push("src-tauri/capabilities/remote.json (removed — no trustedOrigins)");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { profile, written };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
214
|
+
const rootDir = path.resolve(__dirname, "..");
|
|
215
|
+
const profileName = resolveProfileName(rootDir);
|
|
216
|
+
const { profile, written } = configure(rootDir, profileName);
|
|
217
|
+
|
|
218
|
+
const target = profile.mode === "remote" ? profile.url : "bundled dist/ frontend";
|
|
219
|
+
console.log(`[native-app-wrapper] configured profile "${profile.name}" (${profile.appName} -> ${target})`);
|
|
220
|
+
for (const relPath of written) console.log(` wrote ${relPath}`);
|
|
221
|
+
console.log(` run "npm run icons" if ${profile.name}'s iconSource changed`);
|
|
222
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generates the full platform icon set (Windows .ico, macOS .icns, Linux/
|
|
3
|
+
// Android/iOS PNGs at every required size) from the active profile's single
|
|
4
|
+
// `iconSource` PNG, using the Tauri CLI's own icon generator so the output
|
|
5
|
+
// always matches what the installed @tauri-apps/cli version expects.
|
|
6
|
+
//
|
|
7
|
+
// This is the "I have real artwork" path. `node bin/cli.js icons` is the other
|
|
8
|
+
// one: it draws a neutral placeholder set with no artwork, no dependencies, and
|
|
9
|
+
// no Rust toolchain, so a freshly scaffolded copy builds before anyone has a
|
|
10
|
+
// logo. Run this once you do.
|
|
11
|
+
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { loadProfile, resolveProfileName } from "./profile.mjs";
|
|
17
|
+
|
|
18
|
+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
const profile = loadProfile(rootDir, resolveProfileName(rootDir));
|
|
20
|
+
|
|
21
|
+
if (!profile.iconSource) {
|
|
22
|
+
throw new Error(`profiles/${profile.name}.json has no "iconSource"`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const iconSource = path.resolve(path.dirname(profile.path), profile.iconSource);
|
|
26
|
+
if (!existsSync(iconSource)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`profiles/${profile.name}.json's iconSource does not exist: ${iconSource}\n` +
|
|
29
|
+
"(run `node bin/cli.js icons` to draw a placeholder set instead)",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const outDir = path.join(rootDir, "src-tauri", "icons");
|
|
34
|
+
console.log(`[native-app-wrapper] generating icons from ${path.relative(rootDir, iconSource)} -> src-tauri/icons/`);
|
|
35
|
+
|
|
36
|
+
const result = spawnSync("npx", ["--yes", "@tauri-apps/cli@2", "icon", iconSource, "-o", outDir], {
|
|
37
|
+
cwd: rootDir,
|
|
38
|
+
stdio: "inherit",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (result.status !== 0) {
|
|
42
|
+
console.error(
|
|
43
|
+
"[native-app-wrapper] icon generation failed. Make sure iconSource points at a square PNG " +
|
|
44
|
+
"(1024x1024 recommended) and that the Rust toolchain is installed (the Tauri CLI's icon " +
|
|
45
|
+
"command shells out to it).",
|
|
46
|
+
);
|
|
47
|
+
process.exit(result.status ?? 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(
|
|
51
|
+
"[native-app-wrapper] icons written. Android/iOS project icons are re-derived from these the " +
|
|
52
|
+
"next time `npm run android:init` / `npm run ios:init` regenerates gen/android or gen/apple.",
|
|
53
|
+
);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Draws a complete, buildable icon set from the active profile's colors — no
|
|
3
|
+
// source artwork, no image library, no network. `tauri build` refuses to bundle
|
|
4
|
+
// without every icon in tauri.conf.json's bundle.icon list existing, so a fresh
|
|
5
|
+
// copy of this package would otherwise be un-buildable until someone supplies a
|
|
6
|
+
// logo. This fills that gap with a neutral mark; swap in real artwork later by
|
|
7
|
+
// pointing the profile's `iconSource` at it and running `npm run icons`.
|
|
8
|
+
//
|
|
9
|
+
// Every size is drawn at its own resolution rather than downscaled from one
|
|
10
|
+
// master, so the 16px entries stay legible instead of turning to mush.
|
|
11
|
+
|
|
12
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
blend,
|
|
17
|
+
coverage,
|
|
18
|
+
encodeIcns,
|
|
19
|
+
encodeIco,
|
|
20
|
+
encodePng,
|
|
21
|
+
hexToRgb,
|
|
22
|
+
roundedRectDistance,
|
|
23
|
+
} from "./lib/png.mjs";
|
|
24
|
+
import { loadProfile, resolveProfileName } from "./profile.mjs";
|
|
25
|
+
|
|
26
|
+
/** Windows Store logo assets Tauri's MSIX/AppX metadata expects alongside the core sizes. */
|
|
27
|
+
const SQUARE_LOGOS = {
|
|
28
|
+
"Square30x30Logo.png": 30,
|
|
29
|
+
"Square44x44Logo.png": 44,
|
|
30
|
+
"Square71x71Logo.png": 71,
|
|
31
|
+
"Square89x89Logo.png": 89,
|
|
32
|
+
"Square107x107Logo.png": 107,
|
|
33
|
+
"Square142x142Logo.png": 142,
|
|
34
|
+
"Square150x150Logo.png": 150,
|
|
35
|
+
"Square284x284Logo.png": 284,
|
|
36
|
+
"Square310x310Logo.png": 310,
|
|
37
|
+
"StoreLogo.png": 50,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const PLAIN_PNGS = {
|
|
41
|
+
"32x32.png": 32,
|
|
42
|
+
"64x64.png": 64,
|
|
43
|
+
"128x128.png": 128,
|
|
44
|
+
"128x128@2x.png": 256,
|
|
45
|
+
"icon.png": 1024,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const ICO_SIZES = [16, 32, 48, 64, 128, 256];
|
|
49
|
+
|
|
50
|
+
// OSType -> pixel size. These are the PNG-bearing ICNS entry types; the legacy
|
|
51
|
+
// uncompressed ones aren't needed by any macOS version Tauri supports.
|
|
52
|
+
const ICNS_TYPES = [
|
|
53
|
+
["icp4", 16],
|
|
54
|
+
["icp5", 32],
|
|
55
|
+
["icp6", 64],
|
|
56
|
+
["ic07", 128],
|
|
57
|
+
["ic08", 256],
|
|
58
|
+
["ic09", 512],
|
|
59
|
+
["ic10", 1024],
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Renders the mark: a squircle in the profile's gradient with an outlined
|
|
64
|
+
* "app window" on it — generic enough to read as an application icon for
|
|
65
|
+
* anything this wrapper packages, and distinct per profile through its colors.
|
|
66
|
+
*/
|
|
67
|
+
function render(size, theme) {
|
|
68
|
+
const rgba = Buffer.alloc(size * size * 4);
|
|
69
|
+
const [topColor, bottomColor] = theme.background.map(hexToRgb);
|
|
70
|
+
const accent = hexToRgb(theme.accent);
|
|
71
|
+
|
|
72
|
+
const bgRadius = size * 0.2235; // Apple's squircle corner ratio, near enough
|
|
73
|
+
const inset = size * 0.26;
|
|
74
|
+
const windowRadius = Math.max(size * 0.045, 1);
|
|
75
|
+
const stroke = Math.max(size * 0.05, 1);
|
|
76
|
+
const halfStroke = stroke / 2;
|
|
77
|
+
const titleBarY = inset + size * 0.12;
|
|
78
|
+
// Below ~64px the title-bar dots are smaller than a pixel of ink and just
|
|
79
|
+
// muddy the mark, so the small entries drop them.
|
|
80
|
+
const showDots = size >= 64;
|
|
81
|
+
const dotRadius = size * 0.018;
|
|
82
|
+
// Centered in the strip between the window's top edge and the title bar rule.
|
|
83
|
+
const dotCenterY = (inset + titleBarY) / 2;
|
|
84
|
+
|
|
85
|
+
for (let y = 0; y < size; y++) {
|
|
86
|
+
for (let x = 0; x < size; x++) {
|
|
87
|
+
const px = x + 0.5;
|
|
88
|
+
const py = y + 0.5;
|
|
89
|
+
const index = (y * size + x) * 4;
|
|
90
|
+
|
|
91
|
+
const bg = coverage(roundedRectDistance(px, py, size / 2, size / 2, size / 2, size / 2, bgRadius));
|
|
92
|
+
if (bg > 0) {
|
|
93
|
+
const t = py / size;
|
|
94
|
+
blend(
|
|
95
|
+
rgba,
|
|
96
|
+
index,
|
|
97
|
+
[0, 1, 2].map((c) => Math.round(topColor[c] + (bottomColor[c] - topColor[c]) * t)),
|
|
98
|
+
bg,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const windowDistance = roundedRectDistance(
|
|
103
|
+
px,
|
|
104
|
+
py,
|
|
105
|
+
size / 2,
|
|
106
|
+
size / 2,
|
|
107
|
+
size / 2 - inset,
|
|
108
|
+
size / 2 - inset,
|
|
109
|
+
windowRadius,
|
|
110
|
+
);
|
|
111
|
+
// |distance| - halfStroke turns the filled shape's SDF into its outline.
|
|
112
|
+
blend(rgba, index, accent, coverage(Math.abs(windowDistance) - halfStroke));
|
|
113
|
+
|
|
114
|
+
const onTitleBar =
|
|
115
|
+
windowDistance < 0 && Math.abs(py - titleBarY) <= halfStroke ? 1 : 0;
|
|
116
|
+
if (onTitleBar) blend(rgba, index, accent, 1);
|
|
117
|
+
|
|
118
|
+
if (showDots) {
|
|
119
|
+
for (let i = 0; i < 3; i++) {
|
|
120
|
+
const cx = inset + size * 0.06 + i * size * 0.06;
|
|
121
|
+
const distance = Math.hypot(px - cx, py - dotCenterY) - dotRadius;
|
|
122
|
+
blend(rgba, index, accent, coverage(distance));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return rgba;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Draws the icon set into `<rootDir>/src-tauri/icons` and the 1024px master into
|
|
133
|
+
* `<rootDir>/assets/icon-source.png`. The master is what the profile's
|
|
134
|
+
* `iconSource` points at by default, so a copy of this package is
|
|
135
|
+
* self-consistent — `npm run icons` works — before anyone supplies a logo.
|
|
136
|
+
*/
|
|
137
|
+
export function generatePlaceholderIcons(rootDir, theme) {
|
|
138
|
+
const outDir = path.join(rootDir, "src-tauri", "icons");
|
|
139
|
+
mkdirSync(outDir, { recursive: true });
|
|
140
|
+
const cache = new Map();
|
|
141
|
+
const png = (size) => {
|
|
142
|
+
if (!cache.has(size)) cache.set(size, encodePng(render(size, theme), size));
|
|
143
|
+
return cache.get(size);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const written = [];
|
|
147
|
+
for (const [file, size] of Object.entries({ ...PLAIN_PNGS, ...SQUARE_LOGOS })) {
|
|
148
|
+
writeFileSync(path.join(outDir, file), png(size));
|
|
149
|
+
written.push(file);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
writeFileSync(
|
|
153
|
+
path.join(outDir, "icon.ico"),
|
|
154
|
+
encodeIco(ICO_SIZES.map((size) => ({ size, png: png(size) }))),
|
|
155
|
+
);
|
|
156
|
+
written.push("icon.ico");
|
|
157
|
+
|
|
158
|
+
writeFileSync(
|
|
159
|
+
path.join(outDir, "icon.icns"),
|
|
160
|
+
encodeIcns(ICNS_TYPES.map(([type, size]) => ({ type, png: png(size) }))),
|
|
161
|
+
);
|
|
162
|
+
written.push("icon.icns");
|
|
163
|
+
|
|
164
|
+
const assetsDir = path.join(rootDir, "assets");
|
|
165
|
+
mkdirSync(assetsDir, { recursive: true });
|
|
166
|
+
writeFileSync(path.join(assetsDir, "icon-source.png"), png(1024));
|
|
167
|
+
|
|
168
|
+
return written;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
172
|
+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
173
|
+
const profile = loadProfile(rootDir, resolveProfileName(rootDir));
|
|
174
|
+
const theme = {
|
|
175
|
+
background: ["#1e293b", "#0b1220"],
|
|
176
|
+
accent: "#f8fafc",
|
|
177
|
+
...profile.placeholderIcon,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const written = generatePlaceholderIcons(rootDir, theme);
|
|
181
|
+
console.log(
|
|
182
|
+
`[native-app-wrapper] drew ${written.length} placeholder icons for "${profile.name}" -> src-tauri/icons/`,
|
|
183
|
+
);
|
|
184
|
+
console.log(" wrote assets/icon-source.png (1024x1024 master)");
|
|
185
|
+
console.log(" these are stand-ins — point the profile's iconSource at real artwork and run \"npm run icons\"");
|
|
186
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Minimal RGBA PNG / ICO / ICNS writers, plus the tiny bit of rasterization
|
|
2
|
+
// the placeholder icon needs. Dependency-free on purpose: generating a
|
|
3
|
+
// buildable icon set is a prerequisite for `tauri build`, so it can't be the
|
|
4
|
+
// step that makes a fresh clone need npm install, a native image library, or a
|
|
5
|
+
// network round trip first.
|
|
6
|
+
|
|
7
|
+
import zlib from "node:zlib";
|
|
8
|
+
|
|
9
|
+
const CRC_TABLE = (() => {
|
|
10
|
+
const table = new Int32Array(256);
|
|
11
|
+
for (let n = 0; n < 256; n++) {
|
|
12
|
+
let c = n;
|
|
13
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
14
|
+
table[n] = c;
|
|
15
|
+
}
|
|
16
|
+
return table;
|
|
17
|
+
})();
|
|
18
|
+
|
|
19
|
+
function crc32(buf) {
|
|
20
|
+
let c = 0xffffffff;
|
|
21
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
22
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function chunk(type, data) {
|
|
26
|
+
const head = Buffer.alloc(8);
|
|
27
|
+
head.writeUInt32BE(data.length, 0);
|
|
28
|
+
head.write(type, 4, "ascii");
|
|
29
|
+
const crc = Buffer.alloc(4);
|
|
30
|
+
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
|
|
31
|
+
return Buffer.concat([head, data, crc]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Encodes a `size * size * 4` RGBA buffer as an 8-bit RGBA PNG. */
|
|
35
|
+
export function encodePng(rgba, size) {
|
|
36
|
+
const ihdr = Buffer.alloc(13);
|
|
37
|
+
ihdr.writeUInt32BE(size, 0);
|
|
38
|
+
ihdr.writeUInt32BE(size, 4);
|
|
39
|
+
ihdr[8] = 8; // bit depth
|
|
40
|
+
ihdr[9] = 6; // color type: RGBA
|
|
41
|
+
// 10..12 stay 0: deflate compression, adaptive filtering, no interlace.
|
|
42
|
+
|
|
43
|
+
// Filter type 0 ("none") on every scanline — the shapes here are flat enough
|
|
44
|
+
// that deflate handles them fine without per-row prediction.
|
|
45
|
+
const raw = Buffer.alloc(size * (size * 4 + 1));
|
|
46
|
+
for (let y = 0; y < size; y++) {
|
|
47
|
+
const rowStart = y * (size * 4 + 1);
|
|
48
|
+
raw[rowStart] = 0;
|
|
49
|
+
rgba.copy(raw, rowStart + 1, y * size * 4, (y + 1) * size * 4);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return Buffer.concat([
|
|
53
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
54
|
+
chunk("IHDR", ihdr),
|
|
55
|
+
chunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
|
|
56
|
+
chunk("IEND", Buffer.alloc(0)),
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Packs PNGs into a Windows .ico. Every entry is stored PNG-compressed rather
|
|
62
|
+
* than as a BMP bitmap — supported since Vista, and the only way a 256x256
|
|
63
|
+
* entry stays a reasonable size.
|
|
64
|
+
*/
|
|
65
|
+
export function encodeIco(entries) {
|
|
66
|
+
const header = Buffer.alloc(6);
|
|
67
|
+
header.writeUInt16LE(0, 0); // reserved
|
|
68
|
+
header.writeUInt16LE(1, 2); // type: icon
|
|
69
|
+
header.writeUInt16LE(entries.length, 4);
|
|
70
|
+
|
|
71
|
+
const directory = Buffer.alloc(16 * entries.length);
|
|
72
|
+
let offset = header.length + directory.length;
|
|
73
|
+
entries.forEach((entry, i) => {
|
|
74
|
+
const at = i * 16;
|
|
75
|
+
directory[at] = entry.size >= 256 ? 0 : entry.size; // 0 means 256
|
|
76
|
+
directory[at + 1] = entry.size >= 256 ? 0 : entry.size;
|
|
77
|
+
directory[at + 2] = 0; // palette size (0 = truecolor)
|
|
78
|
+
directory[at + 3] = 0; // reserved
|
|
79
|
+
directory.writeUInt16LE(1, at + 4); // color planes
|
|
80
|
+
directory.writeUInt16LE(32, at + 6); // bits per pixel
|
|
81
|
+
directory.writeUInt32LE(entry.png.length, at + 8);
|
|
82
|
+
directory.writeUInt32LE(offset, at + 12);
|
|
83
|
+
offset += entry.png.length;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return Buffer.concat([header, directory, ...entries.map((e) => e.png)]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Packs PNGs into a macOS .icns. `type` is the four-character OSType that
|
|
91
|
+
* declares the entry's pixel size (icp4 = 16pt, ic07 = 128pt, ...); the
|
|
92
|
+
* PNG-bearing types used here are the ones macOS reads without a legacy
|
|
93
|
+
* uncompressed bitmap fallback.
|
|
94
|
+
*/
|
|
95
|
+
export function encodeIcns(entries) {
|
|
96
|
+
const blocks = entries.map(({ type, png }) => {
|
|
97
|
+
const head = Buffer.alloc(8);
|
|
98
|
+
head.write(type, 0, "ascii");
|
|
99
|
+
head.writeUInt32BE(png.length + 8, 4); // length includes this header
|
|
100
|
+
return Buffer.concat([head, png]);
|
|
101
|
+
});
|
|
102
|
+
const body = Buffer.concat(blocks);
|
|
103
|
+
const head = Buffer.alloc(8);
|
|
104
|
+
head.write("icns", 0, "ascii");
|
|
105
|
+
head.writeUInt32BE(body.length + 8, 4);
|
|
106
|
+
return Buffer.concat([head, body]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Signed distance from `(x, y)` to a rounded rectangle, negative inside. */
|
|
110
|
+
export function roundedRectDistance(x, y, cx, cy, halfW, halfH, radius) {
|
|
111
|
+
const dx = Math.abs(x - cx) - (halfW - radius);
|
|
112
|
+
const dy = Math.abs(y - cy) - (halfH - radius);
|
|
113
|
+
const outside = Math.hypot(Math.max(dx, 0), Math.max(dy, 0));
|
|
114
|
+
return outside + Math.min(Math.max(dx, dy), 0) - radius;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Antialiased coverage for a signed distance, in device pixels. */
|
|
118
|
+
export function coverage(distance) {
|
|
119
|
+
return Math.min(Math.max(0.5 - distance, 0), 1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function hexToRgb(hex) {
|
|
123
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex);
|
|
124
|
+
if (!m) throw new Error(`not a #rrggbb color: ${hex}`);
|
|
125
|
+
const n = parseInt(m[1], 16);
|
|
126
|
+
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Alpha-composites `[r, g, b]` at `alpha` over the pixel at `index`. */
|
|
130
|
+
export function blend(rgba, index, [r, g, b], alpha) {
|
|
131
|
+
if (alpha <= 0) return;
|
|
132
|
+
const a = Math.min(alpha, 1);
|
|
133
|
+
const dstA = rgba[index + 3] / 255;
|
|
134
|
+
const outA = a + dstA * (1 - a);
|
|
135
|
+
if (outA <= 0) return;
|
|
136
|
+
for (let c = 0; c < 3; c++) {
|
|
137
|
+
const src = [r, g, b][c];
|
|
138
|
+
rgba[index + c] = Math.round((src * a + rgba[index + c] * dstA * (1 - a)) / outA);
|
|
139
|
+
}
|
|
140
|
+
rgba[index + 3] = Math.round(outA * 255);
|
|
141
|
+
}
|