diff --git a/README.md b/README.md index 4cb1fdd..d091f3f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,24 @@ License: GPL-3.0-or-later. Built something on wayBetter? Add it to this list with a pull request: name and link, what it extends, one line on what it does. +## Build your own + +```sh +npm create waybetter plugin # a *.plugin.js (or --ts: a TypeScript project) on the real plugin API +npm create waybetter theme # a theme JSON with "$schema", a derived palette and font roles +npm create waybetter app -- --site kick.com # a whole app: Tauri 2 + waybetter-host + runtime + shell +``` + +`create-waybetter` (in `packages/create-waybetter`) asks for what it needs in a terminal, and +takes every answer as a flag (`--help`). The app skeleton is this README in code: the native +`SiteSpec`, the page `SiteAdapter`, the three capability files, a shell that reports the tab +rectangle (`tabs_sync`), the plugin store and one site-only command, with presets for +youtube.com, kick.com and twitch.tv and every site selector marked as a placeholder. Its tests +generate each kind into a temp folder and check it against the real contracts: plugin headers with +`plugins::parse_meta`, plugin code against `createCorePluginApi`'s members, themes against +`theme.schema.json`, the app's TypeScript with `tsc` against the built package and its Rust with +`cargo check`/`cargo test` against this crate. + ## Layout ``` @@ -74,6 +92,8 @@ packages/shell/src/ shell core (TypeScript, no dependencies) updateModel.ts titlebar update chip windowControls.ts caption buttons, resize grips, system menu (Tauri API injected) chrome.ts titlebar/toolbar/resize-border sizes (match policy.rs) +packages/waybetter/ the published npm package (runtime + shell as ESM + .d.ts, theme.schema.json) +packages/create-waybetter/ `npm create waybetter`: plugin, theme and app scaffolds (no dependencies) ``` Both TypeScript packages are plain source (`.ts`, ESM, `type: module`) with no runtime @@ -202,12 +222,16 @@ wayBetter for X adds this repository as a git submodule at `waybetter/` and cons ```sh bun install # typescript + @types/node for the checks -bun run typecheck # tsc over both packages and their tests -bun run test # node --test (runtime + shell) and cargo test (headless host) +bun run typecheck # tsc over both packages and their tests, and create-waybetter (checkJs) +bun run test # node --test (runtime + shell), cargo test (headless host), create-waybetter bun run test:ts bun run test:rust +bun run test:create # scaffolds into temp dirs; its cargo check of an app is ~1 min cold + # (WAYBETTER_SKIP_CARGO_CHECK=1 skips it; needs the local crate cache) +bun run check # typecheck + build:packages + test bun run check:tauri # cargo check with the tauri feature (slow: builds Tauri) bun run build # typecheck + cargo build +bun run pack:check # npm pack --dry-run of waybetter and create-waybetter ``` Requires Bun 1.4.2+, Node 24+ and Rust 1.88+. diff --git a/bun.lock b/bun.lock index 501b592..4c45498 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,13 @@ "typescript": "^5.9.0", }, }, + "packages/create-waybetter": { + "name": "create-waybetter", + "version": "0.1.0", + "bin": { + "create-waybetter": "bin/create-waybetter.js", + }, + }, "packages/runtime": { "name": "@waybetter/runtime", "version": "0.1.0", @@ -17,6 +24,14 @@ "name": "@waybetter/shell", "version": "0.1.0", }, + "packages/waybetter": { + "name": "waybetter", + "version": "0.2.0", + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0", + }, + }, }, "packages": { "@types/node": ["@types/node@24.19.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew=="], @@ -25,8 +40,12 @@ "@waybetter/shell": ["@waybetter/shell@workspace:packages/shell"], + "create-waybetter": ["create-waybetter@workspace:packages/create-waybetter"], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "waybetter": ["waybetter@workspace:packages/waybetter"], } } diff --git a/crates/waybetter-host/examples/plugin_meta.rs b/crates/waybetter-host/examples/plugin_meta.rs new file mode 100644 index 0000000..536d977 --- /dev/null +++ b/crates/waybetter-host/examples/plugin_meta.rs @@ -0,0 +1,32 @@ +//! Prints what the plugin store reads from `*.plugin.js` files, one JSON object per line: +//! `parse_meta` (name, description, version, author) and `valid_file_name`. create-waybetter's +//! tests run it on generated plugins so they are checked with the store's own rules. +//! +//! cargo run --example plugin_meta -- path/to/one.plugin.js [more.plugin.js ...] + +use std::{env, fs, path::Path, process::ExitCode}; +use waybetter_host::plugins::{parse_meta, valid_file_name}; + +fn main() -> ExitCode { + for arg in env::args().skip(1) { + let path = Path::new(&arg); + let file = path.file_name().and_then(|name| name.to_str()).unwrap_or_default(); + let Ok(source) = fs::read_to_string(path) else { + eprintln!("cannot read {arg}"); + return ExitCode::FAILURE; + }; + let meta = parse_meta(file, &source); + println!( + "{}", + serde_json::json!({ + "file": meta.file, + "name": meta.name, + "description": meta.description, + "version": meta.version, + "author": meta.author, + "validFileName": valid_file_name(file), + }) + ); + } + ExitCode::SUCCESS +} diff --git a/package.json b/package.json index 740acfe..f46bece 100644 --- a/package.json +++ b/package.json @@ -10,15 +10,16 @@ "packages/*" ], "scripts": { - "typecheck": "tsc -p tsconfig.json", - "test": "bun run test:ts && bun run test:rust", + "typecheck": "tsc -p tsconfig.json && tsc -p packages/create-waybetter/tsconfig.json", + "test": "bun run test:ts && bun run test:rust && bun run test:create", "test:ts": "node --test packages/runtime/tests/*.test.ts packages/shell/tests/*.test.ts", + "test:create": "node --test packages/create-waybetter/tests/*.test.mjs", "test:rust": "cargo test --manifest-path crates/waybetter-host/Cargo.toml --locked", "check:tauri": "cargo check --manifest-path crates/waybetter-host/Cargo.toml --features tauri", "build": "bun run typecheck && cargo build --manifest-path crates/waybetter-host/Cargo.toml --locked", "build:packages": "cd packages/waybetter && npm run build", - "pack:check": "bun run build:packages && npm pack ./packages/waybetter --dry-run", - "check": "bun run typecheck && bun run test" + "pack:check": "bun run build:packages && npm pack ./packages/waybetter --dry-run && npm pack ./packages/create-waybetter --dry-run", + "check": "bun run typecheck && bun run build:packages && bun run test" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/create-waybetter/LICENSE b/packages/create-waybetter/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/packages/create-waybetter/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/packages/create-waybetter/README.md b/packages/create-waybetter/README.md new file mode 100644 index 0000000..3d9f921 --- /dev/null +++ b/packages/create-waybetter/README.md @@ -0,0 +1,67 @@ +# create-waybetter + +```sh +npm create waybetter +``` + +Scaffolds something for [wayBetter](https://waybetter.fyi), the framework behind wayBetter for X: +a **plugin**, a **theme**, or a whole new **wayBetter app** for another site. It asks what it needs +when run in a terminal; every answer is also a flag. No dependencies, Node 18.3+. + +## Plugin + +```sh +npm create waybetter plugin -- --name "Hide Trends" +npm create waybetter plugin -- --name "Word Badges" --ts # TypeScript project +``` + +One `*.plugin.js` file with the header the plugin store reads (`@name`, `@description`, +`@version`, `@author`) and a class with `start()` / `stop()`. The example uses the real plugin +API: `api.scope()` for cleanup, `api.storage()`, `scope.observe()`, `scope.onKey()`, the UI kit +(`scope.ui.toast` with an action, a settings `ui.dialog` built with `ui.h`) and the theme API +(`api.theme.current()`, `--wb-*` CSS variables). `--ts` makes a small TypeScript project typed by +the `waybetter` package; `npm run build` writes the loadable `dist/.plugin.js` with the header +first. `--app any` targets another wayBetter app (core API only; wayBetter for X adds +`api.tweet`, `api.account`, `api.users`). + +Install: **wayBetter → Plugins → Open folder**, copy the file in, **Rescan**, turn it on. + +## Theme + +```sh +npm create waybetter theme -- --name "Midnight Mint" --accent "#3ddc97" --font "Geist, system-ui" +``` + +A theme JSON with `"$schema": "https://unpkg.com/waybetter/theme.schema.json"` (editor completion +and checks), a full palette derived from `--bg`, `--text` and `--accent` the way the runtime derives +missing roles, and the font roles (`font.family` for the interface, `font.tweet` for post text). +Presets: `midnight`, `paper`, `dusk`. Import it in **wayBetter → Appearance → Import**. + +## App + +```sh +npm create waybetter app -- --site kick.com +npm create waybetter app -- --site youtube.com --name "wayBetter for YouTube" +``` + +A wayBetter app skeleton, the same system as wayBetter for X: a Tauri 2 window with a trusted +local shell and one native child webview per site tab (`waybetter-host`), the page runtime and +shell models from the `waybetter` package, and the site adapter in its three parts: + +- `src-tauri/src/site.rs`: the native `SiteSpec` (hosts, runtime global, account rules); +- `src/site.ts`: the page `SiteAdapter` (plugin API settings, account reader, link rules); +- `src-tauri/capabilities/`: `shell`, `tab-chrome` and `site-tab`, keyed by webview label. + +Presets for `youtube.com`, `kick.com` and `twitch.tv` (any host works); every selector and handle +rule is a marked PLACEHOLDER to check against the live site. The host crate comes from a git +submodule at `waybetter/` (default), a local path (`--host-source path --host-path `) or git +(`--host-source git`). + +## Options + +`npm create waybetter -- --help` lists every flag. `--yes` takes defaults for anything not given, +`--dry-run` prints the files, `--force` writes into a non-empty directory. + +## License + +GPL-3.0-or-later. See [LICENSE](./LICENSE). diff --git a/packages/create-waybetter/bin/create-waybetter.js b/packages/create-waybetter/bin/create-waybetter.js new file mode 100644 index 0000000..268dece --- /dev/null +++ b/packages/create-waybetter/bin/create-waybetter.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { main } from '../src/cli.js'; + +main(process.argv.slice(2)).then( + code => { process.exitCode = code; }, + error => { + console.error(error); + process.exitCode = 1; + }, +); diff --git a/packages/create-waybetter/package.json b/packages/create-waybetter/package.json new file mode 100644 index 0000000..6d22f63 --- /dev/null +++ b/packages/create-waybetter/package.json @@ -0,0 +1,46 @@ +{ + "name": "create-waybetter", + "version": "0.1.0", + "license": "GPL-3.0-or-later", + "type": "module", + "description": "npm create waybetter: scaffold a wayBetter plugin (*.plugin.js, JS or TypeScript), theme (JSON with the waybetter theme schema) or a whole wayBetter app (Tauri 2 + waybetter-host + the waybetter runtime). No dependencies.", + "keywords": [ + "waybetter", + "create", + "scaffold", + "plugin", + "theme", + "tauri" + ], + "homepage": "https://waybetter.fyi", + "repository": { + "type": "git", + "url": "git+https://git.crt.fyi/CRT/waybetter.git", + "directory": "packages/create-waybetter" + }, + "bugs": { + "url": "https://git.crt.fyi/CRT/waybetter/issues" + }, + "bin": { + "create-waybetter": "bin/create-waybetter.js" + }, + "exports": { + ".": "./src/cli.js", + "./package.json": "./package.json" + }, + "files": [ + "bin", + "src", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=18.3.0" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "cd ../.. && node --test packages/create-waybetter/tests/*.test.mjs" + } +} diff --git a/packages/create-waybetter/src/app.js b/packages/create-waybetter/src/app.js new file mode 100644 index 0000000..2f5893b --- /dev/null +++ b/packages/create-waybetter/src/app.js @@ -0,0 +1,1250 @@ +// `npm create waybetter -- app`: a whole wayBetter app skeleton for one site, per "One system for +// every app" in the waybetter README: a Tauri 2 window (trusted local shell + one child webview +// per site tab) on the `waybetter-host` crate, the page runtime and shell models from the +// `waybetter` package, and the site adapter in its three parts (native SiteSpec, page SiteAdapter, +// capabilities). Selectors are placeholders to fill in from the site's own DOM. + +import { deflateSync } from 'node:zlib'; +import { UsageError, camelCase, headerValue, lines, pascalCase, rustStr, slugify, str } from './names.js'; + +/** + * Known starting points. Everything site-specific is a placeholder until checked against the + * live site in devtools: selectors, handle rules, the avatar CDN. + */ +export const SITE_PRESETS = { + 'youtube.com': { + label: 'YouTube', + hosts: ['www.youtube.com'], + apiMarks: ['/youtubei/'], + avatarPrefixes: ['https://yt3.ggpht.com/'], + handle: { min: 3, max: 30, extra: '._-', sample: 'example_channel' }, + selectors: { + item: 'ytd-rich-item-renderer, ytd-video-renderer', + accountLink: 'ytd-active-account-header-renderer a[href^="/@"]', + accountAvatar: '#avatar-btn img', + signedOut: 'ytd-button-renderer a[href*="ServiceLogin"]', + itemLink: 'a[href^="/watch"], a[href^="/@"], a[href^="/shorts/"]', + }, + itemPath: '^/(watch|shorts/|@)', + accent: '#ff0033', + }, + 'kick.com': { + label: 'Kick', + hosts: ['kick.com'], + apiMarks: ['/api/'], + avatarPrefixes: ['https://files.kick.com/'], + handle: { min: 3, max: 25, extra: '_', sample: 'example_user' }, + selectors: { + item: '[data-chat-entry]', + accountLink: 'a[data-testid="user-profile-link"]', + accountAvatar: 'button[data-testid="user-menu"] img', + signedOut: 'button[data-testid="login"]', + itemLink: 'a[href^="/"]', + }, + itemPath: '^/[A-Za-z0-9_]{3,25}(/|$)', + accent: '#53fc18', + }, + 'twitch.tv': { + label: 'Twitch', + hosts: ['www.twitch.tv'], + apiMarks: ['gql.twitch.tv'], + avatarPrefixes: ['https://static-cdn.jtvnw.net/'], + handle: { min: 4, max: 25, extra: '_', sample: 'example_user' }, + selectors: { + item: '[data-a-target="chat-line-message"]', + accountLink: '[data-a-target="user-display-name"]', + accountAvatar: '[data-a-target="user-menu-toggle"] img', + signedOut: '[data-a-target="login-button"]', + itemLink: 'a[data-a-target="preview-card-image-link"]', + }, + itemPath: '^/[A-Za-z0-9_]{4,25}(/|$)', + accent: '#9146ff', + }, +}; + +const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/; + +/** + * @typedef {{ name?: string, site: string, global?: string, identifier?: string, + * hostSource?: string, hostPath?: string, gitUrl?: string }} AppInput + */ + +/** @param {AppInput} input */ +export function normalizeAppOptions(input) { + const raw = String(input.site ?? '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''); + if (!raw) throw new UsageError('--site is required (for example youtube.com, kick.com, twitch.tv).'); + const presetKey = raw.replace(/^www\./, ''); + const preset = SITE_PRESETS[/** @type {keyof typeof SITE_PRESETS} */ (presetKey)]; + if (!HOSTNAME.test(raw)) throw new UsageError(`--site "${input.site}" is not a host name like kick.com.`); + const label = preset?.label ?? pascalCase(presetKey.split('.')[0]); + const site = preset ?? { + label, + hosts: [raw], + apiMarks: ['/api/'], + avatarPrefixes: [`https://${raw}/`], + handle: { min: 1, max: 30, extra: '_', sample: 'example_user' }, + selectors: { + item: '[data-example-item]', + accountLink: '[data-example-account] a[href^="/"]', + accountAvatar: '[data-example-account] img', + signedOut: '[data-example-sign-in]', + itemLink: 'a[href^="/"]', + }, + itemPath: '^/[^/]+/?$', + accent: '#3b82f6', + }; + const product = headerValue('App name', input.name || `wayBetter for ${label}`, { max: 60, required: true }); + const slug = slugify(product, 48); + if (!slug) throw new UsageError(`App name "${product}" needs at least one letter or digit.`); + const runtimeGlobal = String(input.global || pascalCase(product)).trim(); + if (!/^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/.test(runtimeGlobal) || runtimeGlobal === 'wayBetter') { + throw new UsageError(`--global "${runtimeGlobal}" must be a JS identifier (and not "wayBetter", which is the alias every app sets).`); + } + const identifier = String(input.identifier || `com.example.${slug.replace(/-/g, '')}`).trim(); + if (!/^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9][A-Za-z0-9-]*)+$/.test(identifier)) { + throw new UsageError(`--identifier "${identifier}" must be reverse-DNS like com.example.myapp.`); + } + const hostSource = String(input.hostSource || 'submodule'); + if (!['submodule', 'path', 'git'].includes(hostSource)) throw new UsageError('--host-source must be submodule, path or git.'); + if (hostSource === 'path' && !input.hostPath) throw new UsageError('--host-source path needs --host-path .'); + const key = slugify(label, 24) || 'site'; + return { + product, + slug, + crate: slug, + lib: `${slug.replace(/-/g, '_')}_lib`, + label, + site, + runtimeGlobal, + identifier, + hostSource, + hostPath: input.hostPath ? String(input.hostPath).replace(/\\/g, '/') : '', + gitUrl: String(input.gitUrl || 'https://git.crt.fyi/CRT/waybetter.git'), + prefix: `wb-${key}`, + dataKey: camelCase(`wb ${key} plugin`), + origin: `https://${site.hosts[0]}`, + }; +} + +/** @typedef {ReturnType} AppOptions */ + +/** Where `npm run build` writes what native code and Tauri load (tests check these exist). */ +export const APP_OUTPUTS = { runtime: 'src-tauri/runtime/runtime.js', shell: 'dist/index.html' }; + +/** The native commands and who may call them (build.rs manifest and capability files). */ +const COMMANDS = { + shell: ['tabs_sync', 'plugins_list', 'plugin_set_enabled', 'plugins_open_folder'], + tab: ['tab_report'], + site: ['site_ready'], +}; +const permission = command => `allow-${command.replace(/_/g, '-')}`; + +// --------------------------------------------------------------------------------------------- +// Icons: a small RGBA PNG (Tauri decodes it for the window icon) and an ICO wrapping it (the +// Windows resource). Replace both with real art (`npx tauri icon art.png`). + +function crc32(bytes) { + let crc = ~0; + for (const byte of bytes) { + crc ^= byte; + for (let k = 0; k < 8; k += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + return ~crc >>> 0; +} + +function chunk(type, data) { + const out = Buffer.alloc(12 + data.length); + out.writeUInt32BE(data.length, 0); + out.write(type, 4, 'ascii'); + data.copy(out, 8); + out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length); + return out; +} + +/** A `size`x`size` RGBA PNG: a rounded square in `color` with a lighter ring. */ +export function iconPng(size, color) { + const [r, g, b] = [1, 3, 5].map(i => parseInt(color.slice(i, i + 2), 16)); + const raw = Buffer.alloc((size * 4 + 1) * size); + const radius = size * 0.22; + for (let y = 0; y < size; y += 1) { + raw[y * (size * 4 + 1)] = 0; + for (let x = 0; x < size; x += 1) { + const dx = Math.max(radius - x - 0.5, x + 0.5 - (size - radius), 0); + const dy = Math.max(radius - y - 0.5, y + 0.5 - (size - radius), 0); + const inside = Math.hypot(dx, dy) <= radius; + const ring = Math.abs(Math.hypot(x + 0.5 - size / 2, y + 0.5 - size / 2) - size * 0.26) < size * 0.06; + const at = y * (size * 4 + 1) + 1 + x * 4; + raw[at] = ring ? 255 : r; + raw[at + 1] = ring ? 255 : g; + raw[at + 2] = ring ? 255 : b; + raw[at + 3] = inside ? 255 : 0; + } + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // RGBA + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +/** An ICO with one PNG image (Windows Vista+ and the `ico` crate read PNG entries). */ +export function iconIco(png, size) { + const head = Buffer.alloc(22); + head.writeUInt16LE(0, 0); + head.writeUInt16LE(1, 2); // icon + head.writeUInt16LE(1, 4); // one image + head[6] = size >= 256 ? 0 : size; + head[7] = size >= 256 ? 0 : size; + head.writeUInt16LE(1, 10); // planes + head.writeUInt16LE(32, 12); // bits per pixel + head.writeUInt32LE(png.length, 14); + head.writeUInt32LE(22, 18); + return Buffer.concat([head, png]); +} + +// --------------------------------------------------------------------------------------------- +// Rust + +/** @param {AppOptions} o */ +function hostDependency(o) { + if (o.hostSource === 'git') return `waybetter-host = { git = ${rustStr(o.gitUrl)} }`; + if (o.hostSource === 'path') { + const path = /\/crates\/waybetter-host\/?$/.test(o.hostPath) ? o.hostPath.replace(/\/$/, '') : `${o.hostPath.replace(/\/$/, '')}/crates/waybetter-host`; + return `waybetter-host = { path = ${rustStr(path)} }`; + } + return 'waybetter-host = { path = "../waybetter/crates/waybetter-host" }'; +} + +/** @param {AppOptions} o */ +function cargoToml(o) { + return lines( + '[package]', + `name = ${rustStr(o.crate)}`, + 'version = "0.1.0"', + `description = ${rustStr(`${o.product}: ${o.site.hosts[0]} in a light native window, extended in place`)}`, + '# waybetter-host is GPL-3.0-or-later, so an app built on it is too.', + 'license = "GPL-3.0-or-later"', + 'edition = "2021"', + 'rust-version = "1.88"', + '', + '[lib]', + `name = ${rustStr(o.lib)}`, + 'path = "src/lib.rs"', + '', + '[[bin]]', + `name = ${rustStr(o.crate)}`, + 'path = "src/main.rs"', + 'required-features = ["desktop"]', + '', + '[features]', + '# `desktop` is the Tauri app; without it (`--no-default-features`) the site adapter and plugin', + '# host build and test headless.', + 'default = ["desktop"]', + 'desktop = ["dep:tauri", "dep:tauri-build", "waybetter-host/tauri"]', + '', + '[build-dependencies]', + 'tauri-build = { version = "2", features = [], optional = true }', + '', + '[dependencies]', + '# The wayBetter framework: security policy, profiles, plugin store, tab payloads and, with', + '# the desktop feature, its Tauri glue.', + hostDependency(o), + 'tauri = { version = "2", features = ["unstable"], optional = true }', + 'serde = { version = "1", features = ["derive"] }', + 'serde_json = "1"', + 'url = "2"', + ); +} + +function buildRs() { + const all = [...COMMANDS.shell, ...COMMANDS.tab, ...COMMANDS.site]; + return lines( + 'fn main() {', + ' // The app-command manifest: tauri-build generates an `allow-` permission for each,', + ' // which the capability files grant per webview label.', + ' #[cfg(feature = "desktop")]', + ' tauri_build::try_build(tauri_build::Attributes::new().app_manifest(', + ` tauri_build::AppManifest::new().commands(&[${all.map(c => `"${c}"`).join(', ')}]),`, + ' ))', + ' .expect("Tauri build configuration must be valid");', + '}', + ); +} + +/** @param {AppOptions} o */ +function siteRs(o) { + const { handle } = o.site; + const extra = [...handle.extra].map(ch => `b'${ch === "'" ? "\\'" : ch}'`); + const byteRule = extra.length ? `b.is_ascii_alphanumeric() || ${extra.map(ch => `b == ${ch}`).join(' || ')}` : 'b.is_ascii_alphanumeric()'; + return lines( + `//! The native half of the ${o.label} site adapter: which webviews are site tabs, the page`, + '//! runtime\'s global, and how the account seen inside a profile is validated.', + '', + 'use waybetter_host::site::{AccountRules, SiteSpec};', + '', + `/// PLACEHOLDER: a handle ${o.label} could really have (without \`@\`). Check the site's own rules.`, + 'pub fn valid_handle(handle: &str) -> bool {', + ` (${handle.min}..=${handle.max}).contains(&handle.len()) && handle.bytes().all(|b| ${byteRule})`, + '}', + '', + 'pub static SITE: SiteSpec = SiteSpec {', + ` product: ${rustStr(o.product)},`, + ' // Exact https origin(s) of site tabs: the only definition of "a site tab".', + ` hosts: &[${o.site.hosts.map(rustStr).join(', ')}],`, + ' // window. of the page runtime (src/runtime/main.ts); plugins use the', + ' // `window.wayBetter` alias.', + ` runtime_global: ${rustStr(o.runtimeGlobal)},`, + ' accounts: AccountRules {', + ' valid_handle,', + ` // PLACEHOLDER: the site's own avatar CDN; other avatar URLs are dropped.`, + ` avatar_prefixes: &[${o.site.avatarPrefixes.map(rustStr).join(', ')}],`, + ' max_name_chars: 50,', + ' max_avatar_len: 512,', + ' },', + '};', + '', + '#[cfg(test)]', + 'mod tests {', + ' use super::*;', + ' use url::Url;', + '', + ' #[test]', + ' fn only_the_exact_site_origin_is_a_site_tab() {', + ` assert!(SITE.is_origin(&Url::parse(${rustStr(`${o.origin}/some/page`)}).unwrap()));`, + ` assert!(!SITE.is_origin(&Url::parse(${rustStr(`http://${o.site.hosts[0]}/`)}).unwrap()));`, + ` assert!(!SITE.is_origin(&Url::parse(${rustStr(`https://user:pass@${o.site.hosts[0]}/`)}).unwrap()));`, + ' assert!(!SITE.is_origin(&Url::parse("https://example.org/").unwrap()));', + ' }', + '', + ' #[test]', + ' fn handles_follow_the_site_rules() {', + ` assert!(valid_handle(${rustStr(handle.sample)}));`, + ' assert!(!valid_handle(""));', + ' assert!(!valid_handle("has space"));', + ' }', + '}', + ); +} + +/** @param {AppOptions} o */ +function pluginsRs(o) { + return lines( + '//! BetterDiscord-style plugins for the site page: `*.plugin.js` files in the app-data', + '//! `plugins` folder and custom CSS, on the waybetter-host plugin store.', + '', + 'use std::path::PathBuf;', + 'use waybetter_host::plugins::{PluginHost, PluginStore};', + '', + 'pub static HOST: PluginHost = PluginHost {', + ` runtime_global: ${rustStr(o.runtimeGlobal)},`, + ' // Plugins run only in the top frame of this exact origin.', + ` origin: ${rustStr(o.origin)},`, + ` product: ${rustStr(o.product)},`, + ` css_element_id: ${rustStr(`${o.prefix}-custom-css`)},`, + ' // Written (disabled) into a freshly created plugins folder.', + ' example: Some(("example.plugin.js", include_str!("../../plugins/example.plugin.js"))),', + ' // First-party plugins shipped with the app: written disabled, upgraded only while untouched.', + ' bundled: &[],', + '};', + '', + '/// The plugin store in `dir` (the app-data `plugins` folder).', + 'pub fn store(dir: PathBuf) -> PluginStore {', + ' PluginStore::new(dir, &HOST)', + '}', + '', + '#[cfg(test)]', + 'mod tests {', + ' use super::*;', + ' use crate::site::SITE;', + '', + ' #[test]', + ' fn plugins_run_on_the_site_origin_with_its_runtime() {', + ' assert_eq!(HOST.origin, SITE.primary_origin());', + ' assert_eq!(HOST.runtime_global, SITE.runtime_global);', + ' }', + '', + ' #[test]', + ' fn the_example_plugin_has_a_header() {', + ' let (file, source) = HOST.example.unwrap();', + ' assert!(waybetter_host::plugins::valid_file_name(file));', + ' assert_eq!(waybetter_host::plugins::parse_meta(file, source).name, "Example");', + ' }', + '}', + ); +} + +/** @param {AppOptions} o */ +function libRs(o) { + return lines( + `//! ${o.product}: ${o.site.hosts[0]} in one light native window (a trusted local shell and one`, + '//! child webview per site tab), extended in place with the wayBetter page runtime and plugins.', + '', + 'pub mod plugins;', + 'pub mod site;', + '', + '#[cfg(feature = "desktop")]', + 'mod desktop;', + '', + '#[cfg(feature = "desktop")]', + 'pub use desktop::run;', + ); +} + +/** @param {AppOptions} o */ +function mainRs(o) { + return lines( + '#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]', + '', + 'fn main() {', + ` ${o.lib}::run();`, + '}', + ); +} + +/** @param {AppOptions} o */ +function desktopRs(o) { + return lines( + '//! The native window: the trusted local `shell` webview fills it, and each tab is a native', + '//! child webview placed over the pane rectangle the shell reports (`tabs_sync`). Every', + '//! command re-checks its caller: the capability files are not the boundary.', + '', + 'use crate::{plugins, site::SITE};', + 'use serde::{Deserialize, Serialize};', + 'use std::collections::HashSet;', + 'use std::sync::{Mutex, MutexGuard};', + 'use tauri::webview::{NewWindowResponse, PageLoadEvent};', + 'use tauri::{AppHandle, LogicalPosition, LogicalSize, Manager, State, Webview, WebviewBuilder, WebviewUrl};', + 'use waybetter_host::plugins::{PluginStore, ScriptPlugin};', + 'use waybetter_host::profiles::Account;', + 'use waybetter_host::tabs::{parse_web_url, TabLayout, TabState};', + 'use waybetter_host::{host, policy};', + '', + '/// The page runtime (src/runtime/main.ts), written by `npm run build`.', + 'const RUNTIME: &str = include_str!("../runtime/runtime.js");', + '/// Reports every tab\'s URL and title to the address bar. It may call only `tab_report`.', + 'const TAB_BRIDGE: &str = include_str!("../runtime/tab-bridge.js");', + '', + 'struct AppState {', + ' plugins: Mutex,', + '}', + '', + 'fn store<\'a>(state: &\'a State<\'_, AppState>) -> Result, String> {', + ' state.plugins.lock().map_err(|_| "PLUGINS: state unavailable".to_string())', + '}', + '', + '// ---------------------------------------------------------------------------------------------', + '// Shell-only commands', + '', + '/// Reconciles tab webviews with the shell\'s panes: creates missing ones, places and shows or', + '/// hides all of them, and closes webviews whose tab is gone.', + '#[tauri::command]', + 'async fn tabs_sync(webview: Webview, app: AppHandle, items: Vec) -> Result<(), String> {', + ' host::shell_only(&webview)?;', + ' let window = webview.window();', + ' let scale = window.scale_factor().map_err(|error| format!("TAB: {error}"))?;', + ' let size = window.inner_size().map_err(|error| format!("TAB: {error}"))?.to_logical::(scale);', + ' let maximized = window.is_maximized().unwrap_or(false);', + ' let frameless = !window.is_decorated().unwrap_or(true);', + ' let mut keep = HashSet::new();', + ' for item in items {', + ' if !policy::valid_tab_id(&item.id) {', + ' return Err("VALIDATION: invalid tab id".into());', + ' }', + ' // One account profile in this skeleton; see waybetter-host `profiles` to add more.', + ' if item.profile != waybetter_host::profiles::DEFAULT_PROFILE {', + ' return Err("VALIDATION: unknown profile".into());', + ' }', + ' keep.insert(item.id.clone());', + ' let rect = policy::clamp_tab_rect(item.x, item.y, item.width, item.height, size.width, size.height, maximized, frameless)?;', + ' let visible = item.visible && rect.is_some();', + ' let (x, y, width, height) = rect.unwrap_or((0.0, policy::TITLEBAR_H, 1.0, 1.0));', + ' let tab = match host::tab_webview(&app, &item.id) {', + ' Some(tab) => tab,', + ' None => {', + ' let url = parse_web_url(&item.url)?;', + ' window', + ' .add_child(tab_builder(&app, &item.id, url), LogicalPosition::new(x, y), LogicalSize::new(width, height))', + ' .map_err(|error| format!("TAB: {error}"))?', + ' }', + ' };', + ' host::place_child(&tab, x, y, width, height);', + ' host::set_tab_visible(&tab, visible, item.parked || rect.is_none());', + ' }', + ' for existing in window.webviews() {', + ' if policy::tab_id(existing.label()).is_some_and(|id| !keep.contains(id)) {', + ' let _ = existing.close();', + ' }', + ' }', + ' Ok(())', + '}', + '', + '#[tauri::command]', + 'async fn plugins_list(webview: Webview, state: State<\'_, AppState>) -> Result, String> {', + ' host::shell_only(&webview)?;', + ' store(&state)?.list()', + '}', + '', + '/// Turns a plugin on or off and starts/stops it live in every site tab.', + '#[tauri::command]', + 'async fn plugin_set_enabled(', + ' webview: Webview,', + ' app: AppHandle,', + ' state: State<\'_, AppState>,', + ' file: String,', + ' enabled: bool,', + ') -> Result, String> {', + ' host::shell_only(&webview)?;', + ' let store = store(&state)?;', + ' // Built first, so a broken file is reported before it is marked enabled.', + ' let script = if enabled {', + ' store.start_script(&file)?', + ' } else {', + ' waybetter_host::plugins::stop_script(&plugins::HOST, &file)?', + ' };', + ' store.set_enabled(&file, enabled)?;', + ' let list = store.list()?;', + ' drop(store);', + ' host::eval_in_site_tabs(&app, &SITE, &script);', + ' Ok(list)', + '}', + '', + '#[tauri::command]', + 'async fn plugins_open_folder(webview: Webview, state: State<\'_, AppState>) -> Result<(), String> {', + ' host::shell_only(&webview)?;', + ' let dir = {', + ' let store = store(&state)?;', + ' store.ensure()?;', + ' store.dir().to_path_buf()', + ' };', + ' #[cfg(target_os = "windows")]', + ' let opener = "explorer";', + ' #[cfg(target_os = "macos")]', + ' let opener = "open";', + ' #[cfg(not(any(target_os = "windows", target_os = "macos")))]', + ' let opener = "xdg-open";', + ' std::process::Command::new(opener).arg(dir).spawn().map(|_| ()).map_err(|error| format!("PLUGINS: {error}"))', + '}', + '', + '// ---------------------------------------------------------------------------------------------', + '// Tab commands', + '', + '#[derive(Deserialize)]', + '#[serde(rename_all = "camelCase")]', + 'struct TabReport {', + ' url: Option,', + ' title: Option,', + ' account: Option,', + ' #[serde(default)]', + ' signed_out: bool,', + '}', + '', + '#[derive(Clone, Serialize)]', + 'struct TabAccount {', + ' id: String,', + ' /// None: signed out (or a handle the site could not have).', + ' account: Option,', + '}', + '', + '/// Any tab, about itself only: its URL/title for the address bar and, from a tab that is on the', + '/// site right now, the account the site\'s own UI shows.', + '#[tauri::command]', + 'async fn tab_report(webview: Webview, app: AppHandle, report: TabReport) -> Result<(), String> {', + ' let id = host::tab_only(&webview)?;', + ' if report.url.is_some() || report.title.is_some() {', + ' // The URL is the webview\'s own, never the page\'s claim.', + ' let url = webview.url().ok().map(|url| url.to_string());', + ' host::emit_tab_state(&app, TabState::reported(id.clone(), url, report.title, None, None));', + ' }', + ' if (report.account.is_some() || report.signed_out) && host::site_tab_only(&SITE, &webview).is_ok() {', + ' let account = report.account.and_then(|account| account.validated(&SITE.accounts));', + ' host::emit_shell(&app, "tab-account", TabAccount { id, account });', + ' }', + ' Ok(())', + '}', + '', + '#[derive(Clone, Serialize)]', + 'struct SiteReady {', + ' id: String,', + ' url: String,', + '}', + '', + '/// A site-only command: callable only from a `tab-*` webview that is on the exact site origin', + '/// (capability `site-tab`, re-checked here). Add the app\'s own site commands the same way.', + '#[tauri::command]', + 'async fn site_ready(webview: Webview, app: AppHandle) -> Result<(), String> {', + ' let url = host::site_tab_only(&SITE, &webview)?;', + ' let id = host::tab_only(&webview)?;', + ' host::emit_shell(&app, "site-ready", SiteReady { id, url: url.to_string() });', + ' Ok(())', + '}', + '', + '// ---------------------------------------------------------------------------------------------', + '// Tab webviews', + '', + 'fn tab_builder(app: &AppHandle, id: &str, url: url::Url) -> WebviewBuilder {', + ' let (load_app, load_id) = (app.clone(), id.to_owned());', + ' let (title_app, title_id) = (app.clone(), id.to_owned());', + ' WebviewBuilder::new(policy::tab_label(id), WebviewUrl::External(url))', + ' .initialization_script(TAB_BRIDGE)', + ' // The runtime checks the origin itself and stays inert on other sites.', + ' .initialization_script(RUNTIME)', + ' // Pop-ups are refused in this skeleton; open them as tabs with `tabs::OpenRequest`.', + ' .on_new_window(|_, _| NewWindowResponse::Deny)', + ' .on_page_load(move |tab, payload| {', + ' let finished = matches!(payload.event(), PageLoadEvent::Finished);', + ' host::emit_tab_state(', + ' &load_app,', + ' TabState { id: load_id.clone(), url: Some(payload.url().to_string()), loading: Some(!finished), ..Default::default() },', + ' );', + ' // Enabled plugins and custom CSS, evaluated natively (the page\'s CSP cannot block them).', + ' if finished && SITE.is_origin(payload.url()) {', + ' let scripts = load_app', + ' .state::()', + ' .plugins', + ' .lock()', + ' .map(|store| {', + ' let mut scripts: Vec = waybetter_host::plugins::css_script(&plugins::HOST, &store.css()).into_iter().collect();', + ' scripts.extend(store.start_scripts());', + ' scripts', + ' })', + ' .unwrap_or_default();', + ' for script in scripts {', + ' let _ = tab.eval(&script);', + ' }', + ' }', + ' })', + ' .on_document_title_changed(move |_, title| {', + ' host::emit_tab_state(&title_app, TabState { id: title_id.clone(), title: Some(title), ..Default::default() });', + ' })', + '}', + '', + '// ---------------------------------------------------------------------------------------------', + '// App', + '', + 'pub fn run() {', + ' tauri::Builder::default()', + ' .setup(|app| {', + ' let store = plugins::store(app.path().app_data_dir()?.join("plugins"));', + ' store.ensure()?;', + ' app.manage(AppState { plugins: Mutex::new(store) });', + '', + ' let window = tauri::window::WindowBuilder::new(app, policy::MAIN_LABEL)', + ' .title(SITE.product)', + ' .inner_size(1280.0, 820.0)', + ' .min_inner_size(720.0, 480.0)', + ' .build()?;', + ' let size = window.inner_size()?.to_logical::(window.scale_factor()?);', + ' // The shell fills the window; tab webviews are layered over its pane rectangles.', + ' let shell = WebviewBuilder::new(policy::SHELL_LABEL, WebviewUrl::App("index.html".into()))', + ' .auto_resize()', + ' .on_navigation(|url| policy::local_navigation_allowed(url, cfg!(debug_assertions)))', + ' .on_new_window(|_, _| NewWindowResponse::Deny);', + ' let shell = window.add_child(shell, LogicalPosition::new(0.0, 0.0), LogicalSize::new(size.width, size.height))?;', + ' host::attach_shell(&shell);', + ' Ok(())', + ' })', + ` .invoke_handler(tauri::generate_handler![${[...COMMANDS.shell, ...COMMANDS.tab, ...COMMANDS.site].join(', ')}])`, + ' .run(tauri::generate_context!())', + ` .expect(${rustStr(`failed to run ${o.product}`)});`, + '}', + ); +} + +/** @param {AppOptions} o */ +function tauriConf(o) { + return { + $schema: 'https://schema.tauri.app/config/2', + productName: o.product, + version: '0.1.0', + identifier: o.identifier, + build: { + beforeDevCommand: 'npm run build', + beforeBuildCommand: 'npm run build', + frontendDist: '../dist', + }, + app: { + withGlobalTauri: true, + // The window is made in desktop.rs (a multiwebview window: shell + tab webviews). + windows: [], + security: { + capabilities: ['shell', 'tab-chrome', 'site-tab'], + csp: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src ipc: http://ipc.localhost; object-src 'none'; base-uri 'none'; frame-src 'none'; form-action 'none'", + }, + }, + bundle: { + active: true, + targets: 'all', + icon: ['icons/icon.png', 'icons/icon.ico'], + }, + }; +} + +/** @param {AppOptions} o */ +function capabilities(o) { + const cap = value => `${JSON.stringify({ $schema: '../gen/schemas/desktop-schema.json', ...value }, null, 2)}\n`; + return [ + { + path: 'src-tauri/capabilities/shell.json', + content: cap({ + identifier: 'shell', + description: 'The trusted local shell: tabs and plugins. No generic filesystem or shell APIs.', + webviews: [/** policy::SHELL_LABEL */ 'shell'], + local: true, + permissions: ['core:event:default', ...COMMANDS.shell.map(permission)], + }), + }, + { + path: 'src-tauri/capabilities/tab-chrome.json', + content: cap({ + identifier: 'tab-chrome', + description: 'Every tab (any site): report its own URL, title and, on the site, its account. Nothing else.', + webviews: ['tab-*'], + local: false, + remote: { urls: ['https://*/*', 'http://*/*'] }, + permissions: COMMANDS.tab.map(permission), + }), + }, + { + path: 'src-tauri/capabilities/site-tab.json', + content: cap({ + identifier: 'site-tab', + description: `Tabs on ${o.site.hosts.join(', ')} only: the app's short, reviewed list of site commands.`, + webviews: ['tab-*'], + local: false, + remote: { urls: o.site.hosts.map(host => `https://${host}/*`) }, + permissions: COMMANDS.site.map(permission), + }), + }, + ]; +} + +// --------------------------------------------------------------------------------------------- +// TypeScript: site adapter, page runtime, shell + +/** @param {AppOptions} o */ +function siteTs(o) { + const s = o.site.selectors; + return lines( + `// The page half of the ${o.label} site adapter (waybetter/runtime/site): every runtime module`, + '// takes only its piece. Must agree with the native `SiteSpec` in src-tauri/src/site.rs.', + '', + "import type { SiteAdapter } from 'waybetter/runtime/site';", + "import type { SeenAccount } from 'waybetter/runtime/accountReport';", + '', + '/**', + ` * PLACEHOLDER selectors: starting guesses for ${o.site.hosts[0]}. Open the site in devtools and`, + ' * replace each one; sites change their markup, so prefer stable attributes (data-*, aria-*).', + ' */', + 'export const SELECTORS = {', + ' /** One item plugins act on (a post, a message, a video card). */', + ` item: ${str(s.item)},`, + ' /** A link to the signed-in account (its href holds the handle). */', + ` accountLink: ${str(s.accountLink)},`, + ' /** The signed-in account\'s avatar image. */', + ` accountAvatar: ${str(s.accountAvatar)},`, + ' /** Only present on the signed-out page. */', + ` signedOut: ${str(s.signedOut)},`, + ' /** Links to the site\'s own pages that the "links" setting may open beside the pane. */', + ` itemLink: ${str(s.itemLink)},`, + '} as const;', + '', + `/** PLACEHOLDER: paths of the site's own pages a click may divert (see links.clickedUrl). */`, + `const ITEM_PATH = new RegExp(${str(o.site.itemPath)});`, + '', + 'function readAccount(root: ParentNode): SeenAccount | null {', + ' const link = root.querySelector(SELECTORS.accountLink);', + ' if (!link) return null;', + " const handle = (link.getAttribute('href') ?? '').replace(/^\\/+@?/, '').split(/[/?#]/)[0] ?? '';", + ' if (!handle) return null;', + ' const avatar = root.querySelector(SELECTORS.accountAvatar)?.src ?? null;', + " return { handle, name: link.textContent?.trim() || handle, avatar };", + '}', + '', + 'function clickedUrl(target: Element | null, location: Location): string | null {', + ' const anchor = target?.closest(SELECTORS.itemLink);', + ' if (!anchor?.href) return null;', + ' const url = new URL(anchor.href, location.href);', + ' return url.origin === location.origin && ITEM_PATH.test(url.pathname) && url.href !== location.href ? url.href : null;', + '}', + '', + 'export const SITE: SiteAdapter = {', + ` product: ${str(o.product)},`, + ` hosts: [${o.site.hosts.map(str).join(', ')}],`, + ` runtimeGlobal: ${str(o.runtimeGlobal)},`, + ' heavyPath: null,', + ` apiMarks: [${o.site.apiMarks.map(str).join(', ')}],`, + ' xhrMarks: [],', + ' plugins: {', + ` name: ${str(o.runtimeGlobal)},`, + ` product: ${str(o.product)},`, + ` storagePrefix: ${str(`${o.prefix}-plugin:`)},`, + ` styleDataKey: ${str(o.dataKey)},`, + ` toastId: ${str(`${o.prefix}-ui`)},`, + ` toastBackground: ${str(o.site.accent)},`, + ' itemSelector: SELECTORS.item,', + " // Keys that start one of the site's own two-key shortcuts.", + ' chordKeys: [],', + ' },', + ' account: { read: readAccount, signedOut: root => !!root.querySelector(SELECTORS.signedOut) },', + " links: { configKey: 'site', clickedUrl },", + '};', + ); +} + +/** @param {AppOptions} o */ +function addressTs(o) { + return lines( + '// Address bar chips (waybetter/shell/addressModel): how the shell names this site\'s pages.', + '', + "import type { AddressSite, Segment } from 'waybetter/shell/addressModel';", + "import { SITE } from './site.ts';", + '', + 'function label(part: string): string {', + ' try { return decodeURIComponent(part); } catch { return part; }', + '}', + '', + 'export const ADDRESS_SITE: AddressSite = {', + " kind: 'site',", + ' hosts: new Set(SITE.hosts),', + ` label: ${str(o.label)},`, + ' home: origin => `${origin}/`,', + ' // PLACEHOLDER: one chip per path part; name the site\'s own pages (a channel, a video) here.', + ' segments(url): Segment[] {', + " const parts = url.pathname.split('/').filter(Boolean).slice(0, 3);", + " return parts.map((part, index) => ({ label: label(part), href: `${url.origin}/${parts.slice(0, index + 1).join('/')}` }));", + ' },', + '};', + ); +} + +function runtimeTs() { + return lines( + '// The page runtime, injected into every tab and active only on the site\'s origin (top frame).', + '// It sets `window.` (and the `window.wayBetter` alias plugins use) with the', + '// plugin API; native code then evaluates the enabled `*.plugin.js` files against it.', + '', + "import { configureRuntime, isSiteUrl } from 'waybetter/runtime/site';", + "import { createCorePluginApi, type CorePluginApi } from 'waybetter/runtime/pluginApi';", + "import { installAccountReport, type Invoke } from 'waybetter/runtime/accountReport';", + "import { installLinkRouting } from 'waybetter/runtime/linkRouting';", + "import { SITE } from '../site.ts';", + '', + 'type Runtime = {', + ' api: CorePluginApi;', + ' /** Running plugins by file (native start/stop scripts manage this). */', + ' plugins: Record;', + ' /** Load errors by plugin file. */', + ' errors: Record;', + '};', + '', + 'type TauriInternals = { invoke(command: string, args: Record): Promise };', + '', + 'function install(): void {', + ' if (window.top !== window || !isSiteUrl(SITE, location.href)) return;', + ' const globals = window as unknown as Record;', + ' if (globals[SITE.runtimeGlobal]) return;', + ' configureRuntime(SITE);', + ' // No theme engine yet: `api.theme` reports the page\'s own colors. Pass `{ theme: source }`', + ' // (a ThemeSource from waybetter/runtime/themeApi) once the app has themes.', + ' const api = createCorePluginApi(window, SITE.plugins);', + ' const runtime: Runtime = { api, plugins: {}, errors: {} };', + ' globals[SITE.runtimeGlobal] = runtime;', + ' globals.wayBetter = runtime;', + '', + ' const ipc = (globals.__TAURI_INTERNALS__ ?? null) as TauriInternals | null;', + ' const invoke: Invoke | undefined = ipc ? (command, args) => ipc.invoke(command, args) : undefined;', + ' if (SITE.account) installAccountReport(window, SITE.account, invoke);', + ' // "same" until native code configures it: in-page clicks are left alone.', + ' if (SITE.links) installLinkRouting(window, SITE.links);', + " invoke?.('site_ready', {}).catch(() => {});", + '}', + '', + 'try {', + ' install();', + '} catch (error) {', + ' console.error(`[${SITE.product}] runtime failed`, error);', + '}', + ); +} + +/** @param {AppOptions} o */ +function shellTs(o) { + return lines( + '// The shell: a trusted local page that fills the window. It draws the toolbar (address chips', + '// from waybetter/shell/addressModel, the account, plugins) and reports where the site tab goes;', + '// native code places the tab\'s own webview over that rectangle (`tabs_sync`).', + '', + "import { parseAddress } from 'waybetter/shell/addressModel';", + "import { TITLEBAR_H } from 'waybetter/shell/chrome';", + "import { ADDRESS_SITE } from '../address.ts';", + "import { SITE } from '../site.ts';", + '', + 'type Plugin = { file: string; name: string; description: string; version: string; author: string; enabled: boolean };', + 'type TabStateEvent = { id: string; url?: string; title?: string; loading?: boolean };', + 'type TabAccountEvent = { id: string; account: { handle: string; name: string; avatar?: string } | null };', + '', + "const TAB_ID = '1';", + 'const { invoke } = window.__TAURI__.core;', + 'const { listen } = window.__TAURI__.event;', + '', + 'const $ = (id: string) => document.getElementById(id) as T;', + "const pane = $('pane');", + "const chips = $('chips');", + "const account = $('account');", + "const pluginsButton = $('plugins-button');", + "const pluginsPanel = $('plugins');", + "const status = $('status');", + "document.documentElement.style.setProperty('--toolbar-h', `${TITLEBAR_H}px`);", + "document.title = SITE.product;", + '', + 'let url = `https://${SITE.hosts[0]}/`;', + 'let overlay = false;', + '', + 'function showError(error: unknown) {', + ' status.textContent = String(error);', + ' status.hidden = false;', + '}', + '', + '/** Tells native code where the tab goes; hidden while a shell overlay (plugins) is open. */', + 'function sync() {', + ' const rect = pane.getBoundingClientRect();', + ' const items = [{ id: TAB_ID, url, profile: \'default\', x: rect.left, y: rect.top, width: rect.width, height: rect.height, visible: !overlay, parked: false }];', + " invoke('tabs_sync', { items }).catch(showError);", + '}', + '', + 'function renderAddress(href: string) {', + ' const model = parseAddress(href, ADDRESS_SITE);', + ' chips.replaceChildren();', + ' if (!model) return;', + ' for (const part of [{ label: model.site.label, href: model.site.href }, ...model.segments]) {', + " const chip = document.createElement('span');", + " chip.className = 'chip';", + ' chip.textContent = part.label;', + ' chip.title = part.href;', + ' chips.append(chip);', + ' }', + '}', + '', + 'async function renderPlugins() {', + ' const plugins = await invoke(\'plugins_list\');', + ' pluginsPanel.replaceChildren();', + ' for (const plugin of plugins) {', + " const row = document.createElement('label');", + " const toggle = document.createElement('input');", + " toggle.type = 'checkbox';", + ' toggle.checked = plugin.enabled;', + " toggle.addEventListener('change', () => {", + " invoke('plugin_set_enabled', { file: plugin.file, enabled: toggle.checked }).then(renderPlugins, showError);", + ' });', + " const text = document.createElement('span');", + " text.textContent = `${plugin.name} ${plugin.version}`.trim();", + ' row.append(toggle, text);', + ' pluginsPanel.append(row);', + ' }', + " const folder = document.createElement('button');", + " folder.textContent = 'Open plugins folder';", + " folder.addEventListener('click', () => { invoke('plugins_open_folder').catch(showError); });", + ' pluginsPanel.append(folder);', + '}', + '', + "pluginsButton.addEventListener('click', () => {", + ' overlay = !overlay;', + ' pluginsPanel.hidden = !overlay;', + " pluginsButton.setAttribute('aria-expanded', String(overlay));", + ' if (overlay) renderPlugins().catch(showError);', + ' sync();', + '});', + '', + "listen('tab-state', ({ payload }) => {", + ' if (payload.id !== TAB_ID || !payload.url) return;', + ' url = payload.url;', + ' renderAddress(url);', + '});', + "listen('tab-account', ({ payload }) => {", + " account.textContent = payload.account ? `@${payload.account.handle}` : 'Signed out';", + '});', + '', + 'new ResizeObserver(sync).observe(pane);', + 'renderAddress(url);', + 'sync();', + ); +} + +function tauriTypes() { + return lines( + '// `window.__TAURI__` in the shell (tauri.conf.json: app.withGlobalTauri). Swap for', + '// `@tauri-apps/api` if you prefer imports.', + '', + 'interface Window {', + ' __TAURI__: {', + ' core: { invoke(command: string, args?: Record): Promise };', + ' event: { listen(event: string, handler: (event: { payload: T }) => void): Promise<() => void> };', + ' };', + '}', + ); +} + +/** @param {AppOptions} o */ +function indexHtml(o) { + return lines( + '', + '', + '', + ' ', + ' ', + ` ${o.product.replace(/[<&]/g, ch => (ch === '<' ? '<' : '&'))}`, + ' ', + ' ', + '', + '', + '
', + ' ', + ' ', + ' ', + '
', + ' ', + ' ', + ' ', + '
', + '', + '', + ); +} + +/** @param {AppOptions} o */ +function shellCss(o) { + return lines( + ':root { color-scheme: dark; --toolbar-h: 44px; --accent: ' + o.site.accent + '; font: 14px/1.4 system-ui, sans-serif; }', + 'html, body { margin: 0; height: 100%; background: #0d0f14; color: #e8eaf0; }', + 'body { display: flex; flex-direction: column; overflow: hidden; }', + '.toolbar { flex: 0 0 var(--toolbar-h); display: flex; align-items: center; gap: 8px; padding: 0 10px; border-bottom: 1px solid #262a35; }', + '.chips { display: flex; gap: 4px; min-width: 0; flex: 1; overflow: hidden; }', + '.chip { padding: 3px 10px; border-radius: 999px; background: #1b1f29; white-space: nowrap; }', + '.chip:first-child { background: var(--accent); color: #000; font-weight: 600; }', + '.account { color: #9aa1b2; }', + 'button { font: inherit; color: inherit; background: #1b1f29; border: 1px solid #2f3442; border-radius: 8px; padding: 5px 12px; cursor: pointer; }', + '.plugins { display: grid; gap: 8px; padding: 16px; }', + '.plugins label { display: flex; gap: 8px; align-items: center; }', + '.status { padding: 8px 12px; background: #3a1418; color: #ffd7d7; }', + '.pane { flex: 1; min-height: 0; }', + ); +} + +/** @param {AppOptions} o */ +function examplePlugin(o) { + return lines( + '/**', + ' * @name Example', + ` * @description Example plugin for ${o.product}: outlines each item and says hello. Turn it on in Plugins.`, + ' * @version 1.0.0', + ` * @author ${o.product}`, + ' */', + 'module.exports = class Example {', + ' start() {', + ' const api = window.wayBetter.api;', + ' this.scope = api.scope();', + ` this.scope.css('example', ${str(`${o.site.selectors.item} { outline: 1px dashed var(--wb-accent); }`)});`, + " this.scope.ui.toast('Example plugin started', { tone: 'success' });", + ' }', + '', + ' stop() {', + ' this.scope.dispose();', + ' }', + '};', + ); +} + +function tabBridge() { + return lines( + '// Injected into every tab (top frame only): reports URL and title changes of single-page apps', + '// to the address bar. It can call only `tab_report` (capability tab-chrome), about this tab.', + '(() => {', + ' if (window.top !== window || window.__wbTabBridge) return;', + ' window.__wbTabBridge = true;', + ' const invoke = (command, args) => {', + ' const ipc = window.__TAURI_INTERNALS__;', + " if (ipc && typeof ipc.invoke === 'function') ipc.invoke(command, args).catch(() => {});", + ' };', + " let last = '';", + ' const report = () => {', + ' const key = `${location.href}\\n${document.title}`;', + ' if (key === last) return;', + ' last = key;', + " invoke('tab_report', { report: { url: location.href, title: document.title } });", + ' };', + ' const soon = () => setTimeout(report, 50);', + " for (const method of ['pushState', 'replaceState']) {", + ' const original = history[method];', + ' history[method] = function (...args) {', + ' const result = original.apply(this, args);', + ' soon();', + ' return result;', + ' };', + ' }', + " addEventListener('popstate', soon);", + " addEventListener('DOMContentLoaded', report);", + '})();', + ); +} + +function buildMjs() { + return lines( + '// Builds the two bundles: the page runtime (one IIFE that native code injects into tabs) and the', + '// shell (the local page Tauri serves from dist/).', + "import { build } from 'esbuild';", + "import { copyFileSync, mkdirSync } from 'node:fs';", + '', + "mkdirSync('dist', { recursive: true });", + 'await build({', + " entryPoints: ['src/runtime/main.ts'],", + ' bundle: true,', + " format: 'iife',", + " target: 'es2022',", + ` outfile: ${str(APP_OUTPUTS.runtime)},`, + " legalComments: 'none',", + '});', + 'await build({', + " entryPoints: ['src/shell/main.ts'],", + ' bundle: true,', + " format: 'esm',", + " target: 'es2022',", + " outfile: 'dist/shell.js',", + '});', + "copyFileSync('src/shell/index.html', 'dist/index.html');", + "copyFileSync('src/shell/shell.css', 'dist/shell.css');", + "console.log('built dist/ and " + APP_OUTPUTS.runtime + "');", + ); +} + +/** @param {AppOptions} o */ +function readme(o) { + const submodule = o.hostSource === 'submodule'; + return lines( + `# ${o.product}`, + '', + `${o.site.hosts[0]} in one light native window, extended in place: a wayBetter app made with`, + '`npm create waybetter`. It is the same system as every wayBetter app: a Tauri 2 window with a', + 'trusted local **shell** and one native child webview per **tab**, the **page runtime** injected', + 'into tabs on the site, and a small **site adapter**.', + '', + '## Run it', + '', + '```sh', + ...(submodule ? [`git init && git submodule add ${o.gitUrl} waybetter # the framework (host crate)`] : []), + 'npm install', + 'npm run build # dist/ (shell) and ' + APP_OUTPUTS.runtime + ' (page runtime)', + 'npm run tauri dev # the app', + 'npm run test:rust # site adapter + plugin host tests, headless', + '```', + '', + 'Requires Node 18+, Rust 1.88+ and the Tauri 2 prerequisites for your platform.', + '', + '## Layout', + '', + '```', + 'src/site.ts SiteAdapter: the page half of the adapter (PLACEHOLDER selectors)', + 'src/address.ts address bar chips for the site\'s pages', + 'src/runtime/main.ts page runtime: window.' + o.runtimeGlobal + ' (+ window.wayBetter), plugin API, account report', + 'src/shell/ the shell page: toolbar, plugins, tabs_sync', + 'plugins/example.plugin.js written (disabled) into a new plugins folder', + 'src-tauri/src/site.rs SiteSpec: the native half (hosts, runtime global, account rules)', + 'src-tauri/src/plugins.rs the plugin store (PluginHost)', + 'src-tauri/src/desktop.rs window, shell, tab webviews and the commands', + 'src-tauri/capabilities/ shell / tab-chrome / site-tab, keyed by webview label', + '```', + '', + '## Make it yours', + '', + `1. Open ${o.origin} in a browser's devtools and replace every PLACEHOLDER in \`src/site.ts\``, + ' and `src-tauri/src/site.rs` (selectors, handle rules, the avatar CDN).', + '2. Keep `hosts` and the runtime global identical in `site.ts` and `site.rs`.', + '3. Add site-only commands like `site_ready`: `host::site_tab_only(&SITE, &webview)?` first,', + ' then list them in `build.rs` and `capabilities/site-tab.json`.', + '4. Next steps from the framework: account profiles (`waybetter_host::profiles`), split panes', + ' (`waybetter/shell/paneModel`), the rail hider (`waybetter/runtime/paneNav`), themes', + ' (`waybetter/runtime/themeApi`, `theme.schema.json`), signed updates (`waybetter_host::update`).', + '', + '## Security model', + '', + '- Only the local `shell` webview may call shell commands (`host::shell_only`).', + '- Any tab may only report about itself (`tab_report`); the URL used is the webview\'s own.', + '- Tabs on the exact site origin may call the short list in `site-tab.json`, re-checked natively.', + '- Plugins are files the owner copies into the plugins folder; native code evaluates them only', + ' in the site\'s top frame.', + '', + 'License: GPL-3.0-or-later (waybetter-host is GPL-3.0-or-later).', + ); +} + +/** @param {AppOptions} o */ +export function appFiles(o) { + const png = iconPng(64, o.site.accent); + const json = value => `${JSON.stringify(value, null, 2)}\n`; + const pkg = { + name: o.slug, + version: '0.1.0', + private: true, + type: 'module', + license: 'GPL-3.0-or-later', + description: `${o.product}: ${o.site.hosts[0]} in a light native window, extended in place.`, + scripts: { + build: 'node scripts/build.mjs', + typecheck: 'tsc -p tsconfig.json', + tauri: 'tauri', + 'test:rust': 'cargo test --manifest-path src-tauri/Cargo.toml --no-default-features', + }, + dependencies: { waybetter: '^0.2.0' }, + devDependencies: { '@tauri-apps/cli': '^2.0.0', esbuild: '^0.25.0', typescript: '^5.9.0' }, + }; + const tsconfig = { + compilerOptions: { + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + module: 'ESNext', + moduleResolution: 'Bundler', + allowImportingTsExtensions: true, + noEmit: true, + strict: true, + noUnusedLocals: true, + skipLibCheck: true, + isolatedModules: true, + types: [], + }, + include: ['src'], + }; + return [ + { path: 'README.md', content: readme(o) }, + { path: 'package.json', content: json(pkg) }, + { path: 'tsconfig.json', content: json(tsconfig) }, + { path: '.gitignore', content: lines('node_modules/', 'dist/', 'src-tauri/target/', 'src-tauri/gen/', APP_OUTPUTS.runtime) }, + { path: 'scripts/build.mjs', content: buildMjs() }, + { path: 'src/site.ts', content: siteTs(o) }, + { path: 'src/address.ts', content: addressTs(o) }, + { path: 'src/runtime/main.ts', content: runtimeTs() }, + { path: 'src/shell/main.ts', content: shellTs(o) }, + { path: 'src/shell/index.html', content: indexHtml(o) }, + { path: 'src/shell/shell.css', content: shellCss(o) }, + { path: 'src/tauri.d.ts', content: tauriTypes() }, + { path: 'plugins/example.plugin.js', content: examplePlugin(o) }, + { path: 'src-tauri/Cargo.toml', content: cargoToml(o) }, + { path: 'src-tauri/build.rs', content: buildRs() }, + { path: 'src-tauri/tauri.conf.json', content: json(tauriConf(o)) }, + ...capabilities(o), + { path: 'src-tauri/icons/icon.png', content: png }, + { path: 'src-tauri/icons/icon.ico', content: iconIco(png, 64) }, + { path: 'src-tauri/src/main.rs', content: mainRs(o) }, + { path: 'src-tauri/src/lib.rs', content: libRs(o) }, + { path: 'src-tauri/src/site.rs', content: siteRs(o) }, + { path: 'src-tauri/src/plugins.rs', content: pluginsRs(o) }, + { path: 'src-tauri/src/desktop.rs', content: desktopRs(o) }, + { path: 'src-tauri/runtime/tab-bridge.js', content: tabBridge() }, + // Replaced by `npm run build`; here so `cargo check` works before the first build. + { path: APP_OUTPUTS.runtime, content: lines('// Placeholder: `npm run build` writes the page runtime here (src/runtime/main.ts).') }, + ]; +} diff --git a/packages/create-waybetter/src/cli.js b/packages/create-waybetter/src/cli.js new file mode 100644 index 0000000..26ec01e --- /dev/null +++ b/packages/create-waybetter/src/cli.js @@ -0,0 +1,253 @@ +// `npm create waybetter`: scaffolds a wayBetter plugin, theme or app. Interactive when run in a +// terminal without the answers; fully flag-driven otherwise (`--yes` takes the defaults). + +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { createInterface } from 'node:readline/promises'; +import { parseArgs } from 'node:util'; +import { SITE_PRESETS, appFiles, normalizeAppOptions } from './app.js'; +import { UsageError, slugify } from './names.js'; +import { PLUGIN_APPS, normalizePluginOptions, pluginFiles } from './plugin.js'; +import { THEME_MODES, THEME_PRESETS, normalizeThemeOptions, themeFiles } from './theme.js'; + +export const KINDS = ['plugin', 'theme', 'app']; + +/** @type {import('node:util').ParseArgsConfig['options']} */ +const OPTIONS = ({ + type: { type: 'string', short: 't' }, + name: { type: 'string', short: 'n' }, + description: { type: 'string' }, + author: { type: 'string' }, + 'plugin-version': { type: 'string' }, + ts: { type: 'boolean' }, + typescript: { type: 'boolean' }, + app: { type: 'string' }, + mode: { type: 'string' }, + preset: { type: 'string' }, + bg: { type: 'string' }, + text: { type: 'string' }, + accent: { type: 'string' }, + font: { type: 'string' }, + 'post-font': { type: 'string' }, + radius: { type: 'string' }, + schema: { type: 'string' }, + site: { type: 'string', short: 's' }, + global: { type: 'string' }, + identifier: { type: 'string' }, + 'host-source': { type: 'string' }, + 'host-path': { type: 'string' }, + 'git-url': { type: 'string' }, + yes: { type: 'boolean', short: 'y' }, + force: { type: 'boolean' }, + 'dry-run': { type: 'boolean' }, + help: { type: 'boolean', short: 'h' }, + version: { type: 'boolean', short: 'v' }, +}); + +export const HELP = `Usage: npm create waybetter [plugin|theme|app] [dir] -- [options] + npx create-waybetter [plugin|theme|app] [dir] [options] + +Scaffolds a wayBetter plugin, theme or whole app. Asks for anything missing when run in a +terminal; --yes takes the defaults instead. + +Common + -t, --type plugin, theme or app + -n, --name display name (plugin/theme name, or the app's product name) + --author plugin/theme author (default "you") + --description + -y, --yes no questions; use defaults for anything not given + --force write into a directory that is not empty + --dry-run print the files instead of writing them + -h, --help this help + -v, --version print the create-waybetter version + +Plugin (one *.plugin.js with the header the plugin store reads) + --plugin-version plugin version (default 1.0.0) + --ts a TypeScript project (types from the waybetter package) + --app x: wayBetter for X (adds api.tweet/account/users); any: another app + +Theme (JSON with "$schema" pointing at the waybetter theme schema) + --preset ${Object.keys(THEME_PRESETS).join(', ')} (seed colors, default midnight) + --mode ${THEME_MODES.join(', ')} + --bg, --text, --accent <#hex> seed colors; the rest of the palette is derived + --font interface fonts, e.g. "Geist, system-ui" + --post-font post text fonts + --radius <0-28> card corner radius (default 16) + --schema "$schema" as the unpkg URL (default) or ./node_modules path + +App (Tauri 2 + waybetter-host + the waybetter runtime and shell models) + -s, --site ${Object.keys(SITE_PRESETS).join(', ')} or any host name + --global the page runtime's window global (default from the name) + --identifier Tauri bundle identifier (default com.example.) + --host-source submodule (default: ../waybetter as a git submodule), path or git + --host-path with --host-source path: the waybetter repository (or its host crate) + --git-url the waybetter repository for submodule/git + +Examples + npm create waybetter plugin -- --name "Hide Trends" + npm create waybetter theme my-theme -- --name "Midnight Mint" --accent "#3ddc97" --yes + npm create waybetter app wb-kick -- --site kick.com --yes +`; + +/** @typedef {{ stdout: { write(text: string): unknown }, stderr: { write(text: string): unknown }, stdin: NodeJS.ReadableStream & { isTTY?: boolean }, cwd: string }} Io */ + +function packageVersion() { + try { + return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; + } catch { + return 'unknown'; + } +} + +/** Minimal line prompts over readline (no dependencies). */ +function prompter(io) { + /** @type {import('node:readline/promises').Interface | null} */ + let rl = null; + const ask = async (question, fallback = '') => { + rl ??= createInterface({ input: io.stdin, output: /** @type {any} */ (io.stdout) }); + const answer = (await rl.question(`${question}${fallback ? ` (${fallback})` : ''}: `)).trim(); + return answer || fallback; + }; + return { + ask, + async choose(question, choices, fallback) { + for (;;) { + const answer = (await ask(`${question} [${choices.join('/')}]`, fallback)).toLowerCase(); + if (choices.includes(answer)) return answer; + io.stdout.write(` Choose one of: ${choices.join(', ')}\n`); + } + }, + async confirm(question, fallback) { + const answer = (await ask(`${question} [y/n]`, fallback ? 'y' : 'n')).toLowerCase(); + return answer.startsWith('y'); + }, + close() { rl?.close(); }, + }; +} + +/** + * Runs the CLI. Returns the exit code; never calls process.exit (tests call it in-process). + * @param {string[]} argv arguments after the program name + * @param {Partial} [ioInput] + */ +export async function main(argv, ioInput = {}) { + /** @type {Io} */ + const io = { stdout: process.stdout, stderr: process.stderr, stdin: process.stdin, cwd: process.cwd(), ...ioInput }; + let parsed; + try { + parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true }); + } catch (error) { + io.stderr.write(`${/** @type {Error} */ (error).message}\n\nRun with --help for usage.\n`); + return 1; + } + /** @type {Record} */ + const values = parsed.values; + const positionals = parsed.positionals; + if (values.help) { io.stdout.write(HELP); return 0; } + if (values.version) { io.stdout.write(`${packageVersion()}\n`); return 0; } + + const interactive = !values.yes && Boolean(io.stdin.isTTY); + const ui = prompter(io); + try { + // `npm create waybetter plugin [dir]`: a leading kind is the type. + let type = values.type ?? (KINDS.includes(positionals[0] ?? '') ? positionals.shift() : undefined); + if (!type) { + if (!interactive) throw new UsageError('--type is required (plugin, theme or app) when not asking questions.'); + type = await ui.choose('What do you want to make?', KINDS, 'plugin'); + } + if (!KINDS.includes(type)) throw new UsageError(`--type must be one of: ${KINDS.join(', ')}.`); + const ask = async (value, question, fallback) => value ?? (interactive ? await ui.ask(question, fallback) : fallback); + + /** @type {Array<{ path: string, content: string | Uint8Array }>} */ + let files; + let defaultDir; + let next; + const warnings = []; + if (type === 'plugin') { + const name = await ask(values.name, 'Plugin name', 'My Plugin'); + const options = normalizePluginOptions({ + name, + description: await ask(values.description, 'Description', `${name}, a wayBetter plugin.`), + author: await ask(values.author, 'Author', 'you'), + version: values['plugin-version'], + typescript: values.ts ?? values.typescript ?? (interactive ? await ui.confirm('TypeScript?', false) : false), + app: values.app ?? (interactive ? await ui.choose('Which app is it for? (x = wayBetter for X)', PLUGIN_APPS, 'x') : 'x'), + }); + files = pluginFiles(options); + defaultDir = options.id; + next = options.typescript + ? ['npm install', 'npm run build', `copy dist/${options.file} into wayBetter → Plugins → Open folder, then Rescan`] + : [`copy ${options.file} into wayBetter → Plugins → Open folder, then Rescan`]; + } else if (type === 'theme') { + const name = await ask(values.name, 'Theme name', 'My Theme'); + const preset = await ask(values.preset, `Start from (${Object.keys(THEME_PRESETS).join(', ')})`, 'midnight'); + const seed = THEME_PRESETS[/** @type {keyof typeof THEME_PRESETS} */ (preset)] ?? THEME_PRESETS.midnight; + const options = normalizeThemeOptions({ + name, + preset, + author: await ask(values.author, 'Author', 'you'), + description: values.description, + mode: values.mode, + bg: await ask(values.bg, 'Background color', seed.bg), + text: await ask(values.text, 'Text color', seed.text), + accent: await ask(values.accent, 'Accent color', seed.accent), + font: values.font, + postFont: values['post-font'], + radius: values.radius, + schema: values.schema, + }); + warnings.push(...options.warnings); + files = themeFiles(options); + defaultDir = options.id; + next = [`import ${options.id}.theme.json in wayBetter → Appearance → Import`]; + } else { + const site = await ask(values.site, `Site (${Object.keys(SITE_PRESETS).join(', ')} or any host)`, 'kick.com'); + const label = SITE_PRESETS[/** @type {keyof typeof SITE_PRESETS} */ (site.replace(/^www\./, ''))]?.label; + const options = normalizeAppOptions({ + site, + name: await ask(values.name, 'App name', label ? `wayBetter for ${label}` : undefined), + global: values.global, + identifier: values.identifier, + hostSource: values['host-source'], + hostPath: values['host-path'], + gitUrl: values['git-url'], + }); + files = appFiles(options); + defaultDir = options.slug; + next = [ + ...(options.hostSource === 'submodule' ? [`git init && git submodule add ${options.gitUrl} waybetter`] : []), + 'npm install', 'npm run build', 'npm run tauri dev', + 'then replace the PLACEHOLDER selectors in src/site.ts and src-tauri/src/site.rs', + ]; + } + + const dirArg = positionals[0] ?? (interactive ? await ui.ask('Directory', defaultDir) : defaultDir); + const target = resolve(io.cwd, dirArg || defaultDir || slugify(type)); + if (values['dry-run']) { + io.stdout.write(`Would write ${files.length} files to ${target}:\n`); + for (const file of files) io.stdout.write(` ${file.path}\n`); + return 0; + } + if (existsSync(target) && readdirSync(target).length > 0 && !values.force) { + throw new UsageError(`${target} is not empty. Pick another directory or pass --force.`); + } + for (const file of files) { + const path = join(target, file.path); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, file.content); + } + const shown = relative(io.cwd, target) || '.'; + io.stdout.write(`\nCreated a wayBetter ${type} in ${shown} (${files.length} files).\n`); + for (const warning of warnings) io.stdout.write(` note: ${warning}\n`); + io.stdout.write(`\nNext:\n${shown === '.' ? '' : ` cd ${shown}\n`}${next.map(step => ` ${step}\n`).join('')}`); + return 0; + } catch (error) { + if (error instanceof UsageError) { + io.stderr.write(`${error.message}\n`); + return 1; + } + throw error; + } finally { + ui.close(); + } +} diff --git a/packages/create-waybetter/src/names.js b/packages/create-waybetter/src/names.js new file mode 100644 index 0000000..70c99c5 --- /dev/null +++ b/packages/create-waybetter/src/names.js @@ -0,0 +1,73 @@ +// Names and small validators shared by the generators. Pure; no I/O. + +/** A usage error: shown to the user as-is, exit code 1, no stack. */ +export class UsageError extends Error {} + +/** `Hide Trends!` -> `hide-trends` (ASCII letters and digits, dashes between). */ +export function slugify(value, max = 64) { + return String(value) + .normalize('NFKD') + .replace(/\p{M}/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, max) + .replace(/-+$/, ''); +} + +/** `hide trends 2` -> `HideTrends2`; always a valid JS/Rust type identifier. */ +export function pascalCase(value) { + const words = String(value).normalize('NFKD').replace(/\p{M}/gu, '').replace(/[^A-Za-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean); + const name = words.map(word => word[0].toUpperCase() + word.slice(1)).join(''); + return /^[A-Za-z]/.test(name) ? name : `Plugin${name}`; +} + +/** `wayBetter for Kick` -> `wayBetterForKick` (camelCase, for `data-*` keys). */ +export function camelCase(value) { + const name = pascalCase(value); + return name[0].toLowerCase() + name.slice(1); +} + +/** + * A value for a `@name` / `@description` / ... plugin header line, or a theme string. The plugin + * store reads each header line up to its end, drops control characters and keeps 300 characters + * (waybetter-host `plugins::parse_meta`), so values are one line and cannot end the comment. + */ +export function headerValue(label, value, { max = 300, required = false } = {}) { + const text = String(value ?? '').trim(); + if (required && !text) throw new UsageError(`${label} is required.`); + if (/[\u0000-\u001f\u007f]/.test(text)) throw new UsageError(`${label} must be one line of text.`); + if (text.includes('*/')) throw new UsageError(`${label} cannot contain "*/".`); + if (text.length > max) throw new UsageError(`${label} is longer than ${max} characters.`); + return text; +} + +/** Semver-ish plugin version (`1.0.0`, `0.3.1-beta.2`). */ +export function versionValue(value) { + const text = String(value ?? '1.0.0').trim() || '1.0.0'; + if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(text)) throw new UsageError(`Version "${text}" is not like 1.0.0.`); + return text; +} + +export const HEX = /^#[0-9a-fA-F]{6}$/; + +/** `#abc` / `abc123` / `#ABC123` -> `#aabbcc` style lowercase six-digit hex. */ +export function hexValue(label, value) { + let text = String(value ?? '').trim().toLowerCase(); + if (!text.startsWith('#')) text = `#${text}`; + const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(text); + if (short) text = `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`; + if (!HEX.test(text)) throw new UsageError(`${label} must be a hex color like #1d9bf0 (got "${value}").`); + return text; +} + +/** A JS/TS string literal. */ +export const str = value => JSON.stringify(String(value)); + +/** A Rust string literal (JSON escapes are valid Rust escapes for the characters we allow). */ +export function rustStr(value) { + return JSON.stringify(String(value)).replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => `\\u{${hex}}`); +} + +/** Joins lines with `\n` and ends the file with one newline. */ +export const lines = (...parts) => `${parts.flat().join('\n').replace(/\n+$/, '')}\n`; diff --git a/packages/create-waybetter/src/plugin.js b/packages/create-waybetter/src/plugin.js new file mode 100644 index 0000000..8c4f643 --- /dev/null +++ b/packages/create-waybetter/src/plugin.js @@ -0,0 +1,426 @@ +// `npm create waybetter -- plugin`: one BetterDiscord-style `*.plugin.js` file (a class with +// start/stop and the header the plugin store reads), optionally as a small TypeScript project +// whose build writes that file. The example uses the real plugin API: `api.scope()`, storage, +// observe, onKey, the UI kit (`scope.ui`: toast with an action, a settings dialog built with +// `ui.h`) and the theme API (`api.theme`, `--wb-*` CSS variables). + +import { UsageError, headerValue, lines, pascalCase, slugify, str, versionValue } from './names.js'; + +/** Which app the plugin targets: wayBetter for X adds post/account readers to the core API. */ +export const PLUGIN_APPS = ['x', 'any']; + +/** + * @typedef {{ name: string, description?: string, author?: string, version?: string, + * typescript?: boolean, app?: string }} PluginInput + * @typedef {{ name: string, description: string, author: string, version: string, + * typescript: boolean, app: 'x' | 'any', id: string, className: string, file: string }} PluginOptions + * @typedef {{ path: string, content: string | Uint8Array }} OutputFile + */ + +/** @param {PluginInput} input @returns {PluginOptions} */ +export function normalizePluginOptions(input) { + const name = headerValue('Plugin name', input.name, { max: 60, required: true }); + const id = slugify(name, 48); + if (!id) throw new UsageError(`Plugin name "${name}" needs at least one letter or digit.`); + const app = String(input.app ?? 'x'); + if (!PLUGIN_APPS.includes(app)) throw new UsageError(`--app must be one of: ${PLUGIN_APPS.join(', ')}.`); + return { + name, + description: headerValue('Description', input.description || `${name}, a wayBetter plugin.`, { max: 200 }), + author: headerValue('Author', input.author || 'you', { max: 60 }), + version: versionValue(input.version), + typescript: Boolean(input.typescript), + app: /** @type {'x' | 'any'} */ (app), + id, + className: pascalCase(name), + // A bare `name.plugin.js`: letters, digits, `-` only (waybetter-host `valid_file_name`). + file: `${id}.plugin.js`, + }; +} + +/** @param {PluginOptions} o */ +function header(o) { + return lines( + '/**', + ` * @name ${o.name}`, + ` * @description ${o.description}`, + ` * @version ${o.version}`, + ` * @author ${o.author}`, + ' */', + ); +} + +/** The item a badge is added to, and how its text is read. @param {PluginOptions} o */ +function itemParts(o) { + if (o.app === 'x') { + return { + selector: `'article[data-testid="tweet"]'`, + selectorNote: '// One post on X.', + text: 'this.api.tweet(item)?.text ?? \'\'', + anchor: `item.querySelector('[data-testid="User-Name"]') ?? item`, + }; + } + return { + selector: `'[data-example-item]'`, + selectorNote: '// PLACEHOLDER: a selector for one item of your app\'s site (a post, a message, a video).', + text: `item.textContent ?? ''`, + anchor: 'item', + }; +} + +/** @param {PluginOptions} o */ +function jsSource(o) { + const item = itemParts(o); + return lines( + header(o).trimEnd(), + '// Made with `npm create waybetter`. The API is documented in PLUGINS.md (wayBetter for X:', + '// docs/PLUGINS.md) and in the waybetter package README (plugin UI kit, themes).', + '', + `const ID = ${str(o.id)}; // storage namespace and CSS id`, + `const ITEM = ${item.selector}; ${item.selectorNote}`, + '', + `module.exports = class ${o.className} {`, + ' start() {', + ' const api = window.wayBetter.api;', + ' this.api = api;', + ' // A scope collects everything the plugin adds; stop() undoes it all with scope.dispose().', + ' this.scope = api.scope();', + ' this.store = api.storage(ID);', + ' this.settings = { badges: true, ...this.store.get(\'settings\', {}) };', + '', + ' // Plugin CSS follows the active theme through the --wb-* variables.', + ' this.scope.css(ID, `', + ' .${ID}-badge { margin-inline-start: 8px; padding: 1px 8px; border: 1px solid var(--wb-border);', + ' border-radius: var(--wb-button-radius); background: var(--wb-surface); color: var(--wb-muted);', + ' font: 500 12px/18px var(--wb-font); white-space: nowrap; }', + ' html[data-${ID}-off] .${ID}-badge { display: none; }', + ' `);', + ' this.apply();', + ' this.scope.defer(() => document.documentElement.removeAttribute(`data-${ID}-off`));', + '', + ' // Once for every matching element, now and as the page renders more (batched per frame).', + ' this.scope.observe(ITEM, item => this.decorate(item));', + '', + ' // A single-key shortcut, skipped while typing: Shift+W opens the settings.', + ' this.scope.onKey(event => { if (event.key === \'W\') this.openSettings(); });', + '', + ' if (!this.store.get(\'welcomed\', false)) {', + ' this.store.set(\'welcomed\', true);', + ` this.scope.ui.toast(${str(`${o.name} is on`)}, { tone: 'success', action: { label: 'Settings', onClick: () => this.openSettings() } });`, + ' }', + ' }', + '', + ' decorate(item) {', + ` const text = ${item.text};`, + ' const words = text.trim() ? text.trim().split(/\\s+/).length : 0;', + ' // scope.add: removed again on dispose.', + ' const badge = this.scope.add(document.createElement(\'span\'));', + ' badge.className = `${ID}-badge`;', + ' badge.textContent = `${words} ${words === 1 ? \'word\' : \'words\'}`;', + ` ${item.anchor}.append(badge);`, + ' }', + '', + ' apply() {', + ' document.documentElement.toggleAttribute(`data-${ID}-off`, !this.settings.badges);', + ' }', + '', + ' openSettings() {', + ' const ui = this.scope.ui; // scoped: the dialog closes if the plugin stops', + ' const h = ui.h; // safe element builder: strings become text, on* functions listeners', + ' const theme = this.api.theme.current();', + ' const badges = h(\'input\', { type: \'checkbox\', checked: this.settings.badges });', + ' ui.dialog({', + ` title: ${str(o.name)},`, + ' message: `Drawn in the active theme: ${theme.name || theme.id}, ${theme.mode}.`,', + ' content: body => body.append(', + ' h(\'label\', { class: \'wb-check\' }, badges, h(\'span\', null, \'Show word counts\', h(\'small\', null, \'A small badge on each item.\'))),', + ' ),', + ' actions: [', + ' { label: \'Cancel\' },', + ' { label: \'Save\', tone: \'primary\', default: true, onClick: handle => {', + ' const next = { ...this.settings, badges: badges.checked };', + ' if (!this.store.set(\'settings\', next)) {', + ' handle.setError(\'Could not save.\');', + ' return false; // keeps the dialog open', + ' }', + ' this.settings = next;', + ' this.apply();', + ' ui.toast(\'Saved\', { tone: \'success\' });', + ' } },', + ' ],', + ' });', + ' }', + '', + ' stop() {', + ' this.scope?.dispose();', + ' this.scope = null;', + ' }', + '};', + ); +} + +/** @param {PluginOptions} o */ +function tsSource(o) { + const item = itemParts(o); + return lines( + header(o).trimEnd(), + '// Made with `npm create waybetter`. `npm run build` writes dist/' + o.file + ' (header first).', + '', + "import type { PluginScope, PluginStorage } from 'waybetter/runtime/pluginApi';", + "import type { PluginApi } from './waybetter-env';", + '', + `const ID = ${str(o.id)}; // storage namespace and CSS id`, + `const ITEM = ${item.selector}; ${item.selectorNote}`, + '', + 'type Settings = { badges: boolean };', + '', + `export default class ${o.className} {`, + ' private api!: PluginApi;', + ' private scope: PluginScope | null = null;', + ' private store!: PluginStorage;', + ' private settings: Settings = { badges: true };', + '', + ' start(): void {', + ' const api = window.wayBetter.api;', + ' this.api = api;', + ' // A scope collects everything the plugin adds; stop() undoes it all with scope.dispose().', + ' const scope = this.scope = api.scope();', + ' this.store = api.storage(ID);', + ' this.settings = { badges: true, ...this.store.get>(\'settings\', {}) };', + '', + ' // Plugin CSS follows the active theme through the --wb-* variables.', + ' scope.css(ID, `', + ' .${ID}-badge { margin-inline-start: 8px; padding: 1px 8px; border: 1px solid var(--wb-border);', + ' border-radius: var(--wb-button-radius); background: var(--wb-surface); color: var(--wb-muted);', + ' font: 500 12px/18px var(--wb-font); white-space: nowrap; }', + ' html[data-${ID}-off] .${ID}-badge { display: none; }', + ' `);', + ' this.apply();', + ' scope.defer(() => document.documentElement.removeAttribute(`data-${ID}-off`));', + '', + ' // Once for every matching element, now and as the page renders more (batched per frame).', + ' scope.observe(ITEM, item => this.decorate(scope, item));', + '', + ' // A single-key shortcut, skipped while typing: Shift+W opens the settings.', + ' scope.onKey(event => { if (event.key === \'W\') this.openSettings(); });', + '', + ' if (!this.store.get(\'welcomed\', false)) {', + ' this.store.set(\'welcomed\', true);', + ` scope.ui.toast(${str(`${o.name} is on`)}, { tone: 'success', action: { label: 'Settings', onClick: () => this.openSettings() } });`, + ' }', + ' }', + '', + ' private decorate(scope: PluginScope, item: Element): void {', + ` const text = ${item.text};`, + ' const words = text.trim() ? text.trim().split(/\\s+/).length : 0;', + ' // scope.add: removed again on dispose.', + ' const badge = scope.add(document.createElement(\'span\'));', + ' badge.className = `${ID}-badge`;', + ' badge.textContent = `${words} ${words === 1 ? \'word\' : \'words\'}`;', + ` ${item.anchor}.append(badge);`, + ' }', + '', + ' private apply(): void {', + ' document.documentElement.toggleAttribute(`data-${ID}-off`, !this.settings.badges);', + ' }', + '', + ' private openSettings(): void {', + ' if (!this.scope) return;', + ' const ui = this.scope.ui; // scoped: the dialog closes if the plugin stops', + ' const h = ui.h; // safe element builder: strings become text, on* functions listeners', + ' const theme = this.api.theme.current();', + ' const badges = h(\'input\', { type: \'checkbox\', checked: this.settings.badges }) as HTMLInputElement;', + ' ui.dialog({', + ` title: ${str(o.name)},`, + ' message: `Drawn in the active theme: ${theme.name || theme.id}, ${theme.mode}.`,', + ' content: body => body.append(', + ' h(\'label\', { class: \'wb-check\' }, badges, h(\'span\', null, \'Show word counts\', h(\'small\', null, \'A small badge on each item.\'))),', + ' ),', + ' actions: [', + ' { label: \'Cancel\' },', + ' { label: \'Save\', tone: \'primary\', default: true, onClick: handle => {', + ' const next: Settings = { ...this.settings, badges: badges.checked };', + ' if (!this.store.set(\'settings\', next)) {', + ' handle.setError(\'Could not save.\');', + ' return false; // keeps the dialog open', + ' }', + ' this.settings = next;', + ' this.apply();', + ' ui.toast(\'Saved\', { tone: \'success\' });', + ' return true;', + ' } },', + ' ],', + ' });', + ' }', + '', + ' stop(): void {', + ' this.scope?.dispose();', + ' this.scope = null;', + ' }', + '}', + ); +} + +/** Types of `window.wayBetter` for the TypeScript project. @param {PluginOptions} o */ +function envTypes(o) { + const x = o.app === 'x'; + return lines( + '// `window.wayBetter`: the page runtime plugins run against. The core API and its types come', + '// from the `waybetter` package; an app adds its own readers on top (wayBetter for X: posts,', + '// the signed-in account and users, see docs/PLUGINS.md).', + "import type { CorePluginApi } from 'waybetter/runtime/pluginApi';", + x ? "import type { SeenAccount } from 'waybetter/runtime/accountReport';" : '', + '', + x ? lines( + '/** A rendered post, read by `api.tweet(element)`. */', + 'export type TweetInfo = {', + ' id: string;', + ' url: string;', + ' handle: string;', + ' name: string;', + ' /** Emoji as characters, t.co links as the destination X shows. */', + ' text: string;', + ' /** ISO timestamp of the post\'s `