commit 558a387b6e5c4879021082ea9c2ccb99afa6139c Author: Rekushi <170186554+RekuNote@users.noreply.github.com> Date: Thu Oct 29 03:55:40 2026 +0000 Add files via upload diff --git a/FORMAT.md b/FORMAT.md new file mode 100644 index 0000000..451f3e7 --- /dev/null +++ b/FORMAT.md @@ -0,0 +1,188 @@ +# Switchnote `.sppm` format + +`.sppm` is Switchnote Studio's own offline animation format. It is not a Nintendo `.ppm` +file. All integers are unsigned little-endian unless stated otherwise. Readers +must check every length before allocating or advancing. `include/sppm.hpp` and +`source/sppm.cpp` are the reference implementation. + +The Fakenote Studio repository supplies 1280×720 UI mockups and 640×360 sample +artwork, with editor buttons for layers A through D. It does **not** specify a +binary format or an exact ink palette. The payload uses a 640×360 +canvas, four indexed layers, and 256 editable RGBA palette entries. Entry 0 is +transparent. The four layers composite D, C, B, A from back to front; pattern +brushes write ordinary palette indices in their chosen pattern. + +## Header + +| Offset | Size | Value | +| ---: | ---: | --- | +| 0 | 4 | ASCII `SPPM` | +| 4 | 2 | Payload version `2` (`1` remains readable) | +| 6 | 2 | Header size `36` | +| 8 | 8 | Total file byte length | +| 16 | 4 | CRC-32/IEEE of bytes from offset 36 to end | +| 20 | 2 | Canvas width `640` | +| 22 | 2 | Canvas height `360` | +| 24 | 1 | Layer count `4` | +| 25 | 2 | Palette count `256` | +| 27 | 1 | Playback FPS, 1–30 | +| 28 | 1 | Flags: bit 0 locked, bit 1 allows spin-offs, bit 2 BGM follows animation speed, bit 3 stops after the final frame (clear = loop) | +| 29 | 4 | Frame count, 1–9999 | +| 33 | 1 | Audio track count, 0–4 | +| 34 | 2 | Reserved zero | + +Maximum file size is 512 MiB. The CRC protects against accidental damage; +it is not an authentication tag. + +## Legacy signed v2 envelope + +Legacy files may use the binary/RLE payload with the header version set to `2`, +followed by the four-byte ASCII marker `SPPS` and a 32-byte HMAC-SHA-256 tag. +The tag covers the complete v2 payload, including its header, metadata, frames +and audio. Readers verify the tag in constant time before decoding the payload. +The HMAC key is retained in an encrypted, console-bound `identity-v2-*.bin` +record. This is a legacy local-file integrity scheme; current app saves use the +publicly verifiable Ed25519-signed v3 envelope below. Neither scheme is +Nintendo's signature system or proof of a real-world creator identity. +Readers accept both old v1 and current v2 payloads inside an authenticated +envelope. +`.sppm` is a compact binary encoding with RLE layers, not plaintext and not an +encrypted format. + +## Public v3 signatures + +Gallery files use the same SPPM payload with its marker set to version `3`, +followed by `SPP3`, public author/original-author/editor identities, and a +64-byte Ed25519 signature. The signed digest covers the complete payload and +all three identities. The server validates the signature and checks each +available FSID against its included public identity. This is self-certifying: +it proves that the included editor key signed those bytes, not that the key +belongs to a Nintendo account or a verified human. + +The Ed25519 secret is encrypted at rest with XSalsa20-Poly1305 and a wrapping +key derived from system-specific console material. On CFW, this material is +available to homebrew and is not a hardware-backed, non-exportable secret; a +compromised console can still expose its signing key. The protection is +intended to make a copied SD identity file unusable on an ordinary different +console, not to resist a compromised device. + +## Palette and metadata + +Immediately after the header are 256 RGBA entries, each four bytes in R, G, +B, A order. The next five fields are UTF-8 strings with `u16 byte length` then +that many bytes: title (at most 256 bytes), description (4096), author name +(128), original author name (128), and last editor name (128). NUL bytes are +forbidden. The reader rejects malformed UTF-8 and preserves valid bytes; +the frontend is responsible for display and editing. + +The default drawing palette uses pink at index 6 and purple at index 10. +When reading older SPPMs, the exact opaque legacy colors `(92,224,36)` and +`(26,40,72)` are remapped to `(239,68,133)` and `(132,66,183)`. Palette indices +are unchanged; saving writes the migrated colors back into the SPPM. + +Six IDs follow, each exactly 16 uppercase hexadecimal ASCII characters: + +1. Author FSID, the current Switchnote creator. +2. Original author FSID, retained through all spin-offs. +3. Last editor FSID. +4. Current note ID, newly allocated for a spin-off. +5. Root note ID, retained through all spin-offs. +6. Parent note ID, the immediate source Switchnote. Sixteen ASCII zeroes encode + an absent parent on an original note. Zeroes are never a valid allocated ID. + +The metadata ends with creation time (`u64` Unix seconds UTC), modification +time (`u64` Unix seconds UTC), and cover frame index (`u32`, zero-based). +Original notes have `currentId == rootId`, no parent, and author FSID equal to +original author FSID. A spin-off must have a new current ID different from its +parent and root IDs. Editing changes the last editor and modification time; +the original author and root ID do not change. + +## Frames + +Frames appear sequentially. Each frame contains four layer records in A–D +order. A layer record has a codec byte (`1` for RLE), a `u32` compressed byte +length, a `u32` CRC-32/IEEE of the compressed bytes, then the compressed data. +Decoded data is exactly 230,400 row-major `u8` palette indices. The decoder +rejects output beyond that size, truncated packets, unknown codecs, and excess +data. A compressed layer may contain at most 460,800 bytes. + +RLE packets have a one-byte command. Bit 7 set means repeat the following +single palette index `(command & 0x7F) + 1` times. Bit 7 clear means copy that +many literal palette indices from the following bytes. Both packet types allow +lengths 1–128. The writer uses repeat packets for runs of at least three. + +## Audio + +Audio track 0 is background music, starting at frame 0. Tracks 1–3 are sound +effects with frame cues. Tracks are stored in order and each record contains: + +| Field | Encoding | +| --- | --- | +| Track index | `u8`, zero-based | +| Channels | `u8`, mono `1` or stereo `2` | +| BGM reference FPS | `u8`, 1–30 for BGM (or 0 to use note speed); zero for SE. Added in payload v2. | +| Sample rate | `u32`, 8000–48000 Hz | +| Name | `u16` byte length + up to 64 UTF-8 bytes | +| Trigger count | `u16` | +| Trigger frames | That many `u16` zero-based frame indices, strictly increasing | +| PCM sample count | `u32`, counts interleaved samples, not stereo pairs | +| PCM CRC | `u32` CRC-32/IEEE of sample bytes | +| PCM | Signed 16-bit little-endian interleaved samples | + +BGM has no triggers. A sound effect may be cued multiple times. Every cue must +refer to an existing frame. Total decoded PCM is limited to 32 Mi samples. +To store SE without music, make track 0 an empty BGM entry with a valid rate +and channel count. + +With the BGM speed flag set, playback rate is `current FPS / reference FPS`: +changing animation speed changes music tempo and pitch together. With the flag +clear, BGM keeps its imported timing. SE tracks always play at their recorded +speed. The v1 payload defaults to speed-linked BGM. +The playback-loop flag defaults to looping for older files. When set, playback +stops all Switchnote tracks at the end of the final frame; when clear, frame zero +and its BGM/SE cues start together on each loop. + +## IDs and spin-offs + +Lineage IDs are **64-bit pseudonymous identifiers**, generated as the first +eight HMAC-SHA-256 digest bytes rendered in uppercase hex. The HMAC key is a +32-byte per-install secret generated with the platform cryptographic random +generator and kept in the encrypted console-local identity record. The +message is: + +``` +ASCII("FlipnoteNX-FSID-v1") || 00 || ASCII(domain) || 00 || 16-byte nonce +``` + +This historical domain string is retained to keep existing Switchnote identities, signatures, and saved files compatible. + +The portable core exposes `deriveFsid(secret, nonce, domain)` so the Switch +frontend can provide randomness through libnx. It uses a fresh random nonce +for each new note ID. Creator FSIDs are the first eight SHA-256 bytes of a +domain-separated Ed25519 public key and a 16-byte one-way device tag. These +values are still 16 hexadecimal characters (64 bits): an FSID is a public +identifier, not a password or proof of authorship. + +Gallery coin and comment writes require a random, single-use, 30-second server +challenge signed with the console's Ed25519 key. The server verifies the +signature, FSID/key binding, request method/path/body, and requesting IP. Coin +limits and poster ownership use the full 256-bit signing-key fingerprint, +independent of the shortened FSID and public device tag. Gallery HTTP is LAN-only and does not encrypt public data in +transit; do not expose the development service to the public internet. + +Creating a spin-off is an explicit operation. It requires the source to allow +spin-offs and not be locked. It copies frames, palette, and audio; keeps the +root note ID and original author credit; sets parent ID to the source current +ID; allocates a new current ID; sets the new author and last editor; resets +creation/modification time; and makes the child editable. The child may be +saved independently without modifying the parent. + +## Safe writing + +Signed saves serialize and validate the complete document, write a sibling +temporary file, then replace the target. If the SD filesystem refuses to +replace an existing file by rename, the old file is moved to a unique sibling +backup, the new file is installed, and the old file is restored if installation +fails. Signed loads authenticate the envelope before replacing the caller's +document; legacy loads still validate all bounds, IDs, frame data, CRCs, and +metadata. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 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 Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + 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 AGPL, see +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1a1c4f7 --- /dev/null +++ b/Makefile @@ -0,0 +1,89 @@ +# Switch homebrew build, based on the devkitPro/switchbrew application template. +.SUFFIXES: + +ifeq ($(strip $(DEVKITPRO)),) +$(error DEVKITPRO is not set; open a devkitPro shell or run build.ps1 on Windows) +endif + +TOPDIR ?= $(CURDIR) + +ifeq ($(wildcard $(DEVKITPRO)/libnx/switch_rules),) +$(error libnx is missing from $(DEVKITPRO); install the switch-dev group) +endif +include $(DEVKITPRO)/libnx/switch_rules + +TARGET := switchnote +BUILD := build +SOURCES := source third_party +INCLUDES := include third_party +ROMFS := romfs + +export APP_TITLE := Switchnote Studio +export APP_AUTHOR := RexiMemo! +export APP_VERSION := 0.1.51 + +ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE +PKG_CONFIG := $(PORTLIBS)/bin/aarch64-none-elf-pkg-config +SDL_PACKAGES := SDL2_image SDL2_ttf SDL2_mixer sdl2 + +CFLAGS := -g -Wall -Wextra -O2 -ffunction-sections $(ARCH) $(DEFINES) +CFLAGS += -D__SWITCH__ $(INCLUDE) $(shell $(PKG_CONFIG) --cflags $(SDL_PACKAGES)) +CXXFLAGS := $(CFLAGS) -std=gnu++17 -fno-rtti -fno-exceptions +ASFLAGS := -g $(ARCH) +LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map) +LIBS := $(shell $(PKG_CONFIG) --static --libs $(SDL_PACKAGES)) -lnx +LIBDIRS := $(PORTLIBS) $(LIBNX) + +ifneq ($(BUILD),$(notdir $(CURDIR))) + +export OUTPUT := $(CURDIR)/$(TARGET) +export TOPDIR := $(CURDIR) +export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) +export DEPSDIR := $(CURDIR)/$(BUILD) + +CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))) +CPPFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.cpp))) +SFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.s))) + +export LD := $(CXX) +export OFILES := $(CPPFILES:.cpp=.o) $(CFILES:.c=.o) $(SFILES:.s=.o) +export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \ + $(foreach dir,$(LIBDIRS),-I$(dir)/include) \ + -I$(CURDIR)/$(BUILD) +export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib) + +# libnx's NRO icon is JPEG. build.ps1 converts a root icon.png if supplied. +ifneq ($(wildcard $(TOPDIR)/icon.jpg),) +export APP_ICON := $(TOPDIR)/icon.jpg +else +export APP_ICON := $(LIBNX)/default_icon.jpg +endif + +export NROFLAGS += --icon=$(APP_ICON) --nacp=$(CURDIR)/$(TARGET).nacp +export NROFLAGS += --romfsdir=$(CURDIR)/$(ROMFS) + +.PHONY: all clean $(BUILD) +all: $(BUILD) + +$(BUILD): + @mkdir -p $@ + @$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile + +clean: + @rm -rf -- $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf $(TARGET).map + +else + +DEPENDS := $(OFILES:.o=.d) +ROMFS_FILES := $(shell find $(TOPDIR)/$(ROMFS) -type f) + +.PHONY: all +all: $(OUTPUT).nro + +$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp $(ROMFS_FILES) + +$(OUTPUT).elf: $(OFILES) + +-include $(DEPENDS) + +endif diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f4728d --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Switchnote Studio + +Switchnote Studio is a Nintendo Switch homebrew app for making frame-by-frame drawings and animations. It began with the artwork and interface assets from Jaames's 2020 Fakenote Studio April Fools' project, then grew into a working editor. + +This is the source snapshot for **Ver 0.1.51**. The included `switchnote.nro` is the original build from the project, with SHA-256 `B0B56FDC81766C8740622AC81BA5E72E90293099CBDB96B5BBF0372AC0C10CCA`. + +## What it does + +- Draw on a 640 × 360 canvas with four layers, pen and brush tools, an eraser, fill, undo and redo. +- Make and play animations at eight speeds, with onion skin and frame controls. +- Add music and frame sound effects. Images can be imported from the Switch album, an IR camera or another device. +- Save signed `.sppm` Switchnotes locally, make spin-offs, and export animations. +- Browse and post through Switchnote Gallery at `https://reximemo.net/nswitch/`. + +The app needs full Homebrew Menu memory. Launch it through App Override; it cannot run in Applet Mode. Put the NRO at `switch/Switchnote/switchnote.nro` on the SD card. User data is stored under `sdmc:/switch/Switchnote/`. + +## Building + +Install devkitPro's Switch toolchain, libnx, `switch-pkg-config`, `switch-sdl2`, `switch-sdl2_image`, `switch-sdl2_ttf` and `switch-sdl2_mixer`. On Windows, run `./build.ps1` from PowerShell. The script expects devkitPro at `C:\devkitPro`, or you can pass `-DevkitProPath`. On Linux or macOS, set up the devkitPro environment and run `make` from a path without spaces. + +The build writes `switchnote.nro` to the project root. `source/` and `include/` contain the app, `romfs/` contains its bundled assets, and `FORMAT.md` describes the `.sppm` format. `server/` contains a local Gallery test server; it is not the production service. The JavaScript and Python readers and converters are in `tools/`. + +## Licences and assets + +The app code is released under the [GNU AGPL version 3](LICENSE). The standalone tools have their own MIT licence in `tools/LICENSE`. Fakenote Studio assets and Bitstream Vera fonts have notices in `licenses/`. diff --git a/SHA256SUMS b/SHA256SUMS new file mode 100644 index 0000000..17cde7f --- /dev/null +++ b/SHA256SUMS @@ -0,0 +1 @@ +B0B56FDC81766C8740622AC81BA5E72E90293099CBDB96B5BBF0372AC0C10CCA switchnote.nro diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..db446ae --- /dev/null +++ b/build.ps1 @@ -0,0 +1,112 @@ +# Build the Switch NRO from Windows using devkitPro's MSYS2 toolchain. +param( + [string]$DevkitProPath = '', + [switch]$KeepBuild +) + +$ErrorActionPreference = 'Stop' + +function Quote-Bash([string]$Value) { + return "'" + $Value.Replace("'", "'\''") + "'" +} + +$projectRoot = [IO.Path]::GetFullPath($PSScriptRoot) +if (-not $DevkitProPath) { + if ($env:DEVKITPRO -and (Test-Path -LiteralPath $env:DEVKITPRO)) { + $DevkitProPath = $env:DEVKITPRO + } else { + $DevkitProPath = 'C:\devkitPro' + } +} +$devkitRoot = [IO.Path]::GetFullPath($DevkitProPath) + +$bash = Join-Path $devkitRoot 'msys2\usr\bin\bash.exe' +$cygpath = Join-Path $devkitRoot 'msys2\usr\bin\cygpath.exe' +$rules = Join-Path $devkitRoot 'libnx\switch_rules' +$compiler = Join-Path $devkitRoot 'devkitA64\bin\aarch64-none-elf-g++.exe' +$pkgConfig = Join-Path $devkitRoot 'portlibs\switch\bin\aarch64-none-elf-pkg-config' + +foreach ($required in @($bash, $cygpath, $rules, $compiler, $pkgConfig)) { + if (-not (Test-Path -LiteralPath $required)) { + throw "Missing Switch build dependency: $required`nInstall devkitPro's Switch toolchain and switch-pkg-config packages; see README.md." + } +} + +if ($devkitRoot -match '\s') { + throw 'The devkitPro installation path contains spaces; install it at C:\devkitPro or choose a path without spaces.' +} + +$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +if ($tempRoot -match '\s') { + throw 'The temporary path contains spaces. Set TEMP to a writable directory without spaces before building.' +} + +# The upstream devkitPro Makefile pattern requires a path without spaces, so +# compile a fresh copy in the temp folder. +$stage = Join-Path $tempRoot ('switchnote-build-' + [Guid]::NewGuid().ToString('N')) +$resolvedStage = [IO.Path]::GetFullPath($stage) +$tempPrefix = $tempRoot.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar +if (-not $resolvedStage.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase) -or + -not ([IO.Path]::GetFileName($resolvedStage) -match '^switchnote-build-[0-9a-f]{32}$')) { + throw "Unexpected temporary build path: $resolvedStage" +} + +New-Item -ItemType Directory -Path $stage | Out-Null +try { + foreach ($entry in @('Makefile', 'source', 'include', 'third_party', 'romfs')) { + $source = Join-Path $projectRoot $entry + if (-not (Test-Path -LiteralPath $source)) { + throw "Project file or directory is missing: $source" + } + Copy-Item -LiteralPath $source -Destination (Join-Path $stage $entry) -Recurse + } + + $rootJpeg = Join-Path $projectRoot 'icon.jpg' + $rootPng = Join-Path $projectRoot 'icon.png' + if (Test-Path -LiteralPath $rootPng) { + try { + Add-Type -AssemblyName System.Drawing + $inputImage = [Drawing.Image]::FromFile($rootPng) + $bitmap = [Drawing.Bitmap]::new(256, 256) + $graphics = [Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.Clear([Drawing.Color]::White) + $graphics.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.DrawImage($inputImage, 0, 0, 256, 256) + $bitmap.Save((Join-Path $stage 'icon.jpg'), [Drawing.Imaging.ImageFormat]::Jpeg) + } finally { + $graphics.Dispose() + $bitmap.Dispose() + $inputImage.Dispose() + } + } catch { + Write-Warning "Could not convert icon.png to JPEG; the default libnx icon will be used. $($_.Exception.Message)" + } + } elseif (Test-Path -LiteralPath $rootJpeg) { + Copy-Item -LiteralPath $rootJpeg -Destination (Join-Path $stage 'icon.jpg') + } + + $devkitUnix = (& $cygpath -u $devkitRoot).Trim() + if ($LASTEXITCODE -ne 0 -or -not $devkitUnix) { throw 'cygpath could not translate the devkitPro path.' } + $stageUnix = (& $cygpath -u $stage).Trim() + if ($LASTEXITCODE -ne 0 -or -not $stageUnix) { throw 'cygpath could not translate the build path.' } + + $devkitQuoted = Quote-Bash $devkitUnix + $stageQuoted = Quote-Bash $stageUnix + $command = 'export DEVKITPRO={0}; export DEVKITA64="$DEVKITPRO/devkitA64"; export PATH="$DEVKITA64/bin:$DEVKITPRO/tools/bin:$DEVKITPRO/portlibs/switch/bin:$PATH"; cd {1}; make -j2' -f $devkitQuoted, $stageQuoted + & $bash -lc $command + if ($LASTEXITCODE -ne 0) { throw "Switch build failed with exit code $LASTEXITCODE." } + + $builtNro = Join-Path $stage 'switchnote.nro' + if (-not (Test-Path -LiteralPath $builtNro)) { throw "Build reported success but no NRO exists at $builtNro" } + $destination = Join-Path $projectRoot 'switchnote.nro' + Copy-Item -LiteralPath $builtNro -Destination $destination -Force + Write-Host "Built $destination" +} finally { + if ($KeepBuild) { + Write-Host "Temporary build retained at $stage" + } elseif (Test-Path -LiteralPath $stage) { + # This directory was created above and its full path was checked against TEMP. + Remove-Item -LiteralPath $stage -Recurse -Force + } +} diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..1319abe Binary files /dev/null and b/icon.png differ diff --git a/include/audio_upload_server.hpp b/include/audio_upload_server.hpp new file mode 100644 index 0000000..cc3eb92 --- /dev/null +++ b/include/audio_upload_server.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include + +class AudioUploadServer { +public: + AudioUploadServer(); + ~AudioUploadServer(); + AudioUploadServer(const AudioUploadServer&) = delete; + AudioUploadServer& operator=(const AudioUploadServer&) = delete; + + bool start(const std::string& bindAddress, const std::string& uploadDirectory, + const std::string& assetsRoot, const std::string& sessionCode, + std::string& error, bool photoTransfer = false, + const std::string& noteSingular = "Switchnote"); + void stop(); + bool isRunning() const; + bool isConnected() const; + bool takeCompletedUpload(int& track); + void finishCompletedUpload(bool success); + +private: + void* impl_ = nullptr; +}; diff --git a/include/branding_bundle.hpp b/include/branding_bundle.hpp new file mode 100644 index 0000000..24f76e5 --- /dev/null +++ b/include/branding_bundle.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace branding { + +struct Bundle { + std::string app; + std::string singular; + std::string plural; + std::vector logo; +}; + +bool writeConfig(const std::string& path, const Bundle& bundle, std::string& error); +bool readConfig(const std::string& path, Bundle& bundle, std::string& error); + +} // namespace branding diff --git a/include/branding_config_server.hpp b/include/branding_config_server.hpp new file mode 100644 index 0000000..12f45d4 --- /dev/null +++ b/include/branding_config_server.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +class BrandingConfigServer { +public: + BrandingConfigServer(); + ~BrandingConfigServer(); + BrandingConfigServer(const BrandingConfigServer&) = delete; + BrandingConfigServer& operator=(const BrandingConfigServer&) = delete; + + bool start(const std::string& bindAddress, const std::string& dataDirectory, + const std::string& assetsRoot, const std::string& sessionCode, + std::string& error, const std::string& appName, + const std::string& noteSingular, const std::string& notePlural); + void stop(); + bool isRunning() const; + bool isConnected() const; + bool takeConfigUpdate(); + +private: + void* impl_ = nullptr; +}; diff --git a/include/export.hpp b/include/export.hpp new file mode 100644 index 0000000..70b41ec --- /dev/null +++ b/include/export.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "sppm.hpp" + +#include + +namespace sppm { + +// Exports a looping GIF89a animation. The GIF uses the document palette and +// composites layers A through D for each frame. GIF has no audio channel. +bool exportGif(const Document& document, const std::string& path, + std::string& error); + +// Exports the selected cover frame as a lossless RGBA PNG. +bool exportPngCover(const Document& document, const std::string& path, + std::string& error); + +// Exports a Motion-JPEG MP4 animation. Frames are composited over white at +// native Switchnote resolution; audio tracks are not muxed. +bool exportMp4(const Document& document, const std::string& path, + std::string& error); + +} // namespace sppm diff --git a/include/sppm.hpp b/include/sppm.hpp new file mode 100644 index 0000000..0b5d5a6 --- /dev/null +++ b/include/sppm.hpp @@ -0,0 +1,162 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace sppm { + +constexpr std::uint16_t kWidth = 640; +constexpr std::uint16_t kHeight = 360; +constexpr std::size_t kPixelsPerLayer = static_cast(kWidth) * kHeight; +constexpr std::size_t kLayerCount = 4; +constexpr std::size_t kPaletteSize = 256; +constexpr std::size_t kMaxFrames = 9999; +constexpr std::size_t kMaxAudioTracks = 4; + +struct Color { + std::uint8_t r = 0; + std::uint8_t g = 0; + std::uint8_t b = 0; + std::uint8_t a = 255; +}; + +using Palette = std::array; + +// Palette index 0 is transparent. Other entries can be changed by the editor. +Palette defaultPalette(); + +struct Frame { + // A, B, C, D from front to back; one palette index per 640x360 pixel. + std::array, kLayerCount> layers; + Frame(); +}; + +struct AudioTrack { + std::string name; // e.g. BGM, SE1, SE2, SE3 + std::uint32_t sampleRate = 0; // 8000..48000 Hz + std::uint8_t channels = 1; // 1 or 2 + std::uint8_t referenceFps = 0; // BGM's native animation speed; 0 means use the note's speed + std::uint8_t referenceSpeed = 0; // SPPM v3 BGM speed index (1..8); 0 means legacy fps + std::vector frameTriggers; // SE cues; BGM always starts at frame 0 + std::vector pcm; // interleaved signed 16-bit samples +}; + +// A public identity is safe to put in an .sppm file. It contains an Ed25519 +// verification key and a one-way, app-specific device tag; neither field +// exposes a raw console identifier. +struct PublicIdentity { + std::array key{}; + std::array deviceTag{}; +}; + +struct SigningIdentity { + PublicIdentity publicIdentity; + std::array secretKey{}; + // Present only to keep validating pre-v3 console-HMAC files after migration. + std::array legacyHmacKey{}; +}; + +struct Metadata { + std::string title; + std::string description; + std::string authorName; + std::string originalAuthorName; + std::string lastEditorName; + + // Creator identities, not file IDs. Exactly 16 uppercase hexadecimal chars. + std::string authorFsid; + std::string originalAuthorFsid; + std::string lastEditorFsid; + + // Present for v3 public-key-signed files. Zero values indicate a legacy + // author record which cannot be retrospectively cryptographically bound. + PublicIdentity authorIdentity; + PublicIdentity originalAuthorIdentity; + PublicIdentity lastEditorIdentity; + + // File lineage IDs. parentId is empty only for an original Switchnote. + std::string currentId; + std::string rootId; + std::string parentId; + + std::uint64_t createdUnixSeconds = 0; + std::uint64_t modifiedUnixSeconds = 0; + std::uint32_t coverFrame = 0; + std::uint8_t fps = 12; + std::uint8_t speed = 0; // SPPM v3 DSi speed index (1..8); 0 means legacy fps + bool locked = false; + bool allowSpinoffs = true; + bool bgmFollowsSpeed = true; + bool loopPlayback = true; +}; + +struct Document { + Metadata metadata; + Palette palette = defaultPalette(); + std::vector frames; + std::vector audioTracks; +}; + +bool isValidFsid(std::string_view fsid); +bool hasPublicIdentity(const PublicIdentity& identity); +std::string deriveFsid(const PublicIdentity& identity); +std::string identityFingerprint(const PublicIdentity& identity); +std::array deriveDeviceTag(const std::vector& deviceMaterial); +std::array deriveDeviceWrapKey(const std::vector& deviceMaterial, + const std::array& salt); +bool createSigningIdentity(const std::array& deviceTag, + SigningIdentity& identity, std::string& error); +bool sealSigningSecret(const SigningIdentity& identity, + const std::array& wrapKey, + const std::array& nonce, + std::array& sealed, std::string& error); +bool unsealSigningSecret(const std::array& sealed, + const std::array& wrapKey, + const std::array& nonce, + SigningIdentity& identity, std::string& error); + +// Domain-separated 64-bit ID helper used for non-identity note and lineage IDs. +// Creator FSIDs are instead derived from PublicIdentity. +std::string deriveFsid(const std::array& secret, + const std::array& nonce, + std::string_view domain); + +bool validate(const Document& document, std::string& error); +double frameRate(const Metadata& metadata); +bool serialize(const Document& document, std::vector& bytes, + std::string& error); +bool deserialize(const std::vector& bytes, Document& document, + std::string& error); +bool loadFile(const std::string& path, Document& document, std::string& error); +bool saveFile(const std::string& path, const Document& document, std::string& error); +// Console-local authenticated .sppm envelopes. Legacy v1 notes remain readable; +// new saves include a 256-bit HMAC-SHA-256 footer verified with the local key. +bool deserializeSigned(const std::vector& bytes, Document& document, + const std::array& signingKey, + std::string& error); +bool loadSignedFile(const std::string& path, Document& document, + const std::array& signingKey, + std::string& error); +bool saveSignedFile(const std::string& path, const Document& document, + const std::array& signingKey, + std::string& error); +// v3 is publicly verifiable: no console-local secret is needed to validate a +// received file. It signs a domain-separated SHA-256 digest with Ed25519. +bool savePublicSignedFile(const std::string& path, const Document& document, + const SigningIdentity& identity, std::string& error); +bool signMessage(const SigningIdentity& identity, const std::uint8_t* message, + std::size_t messageSize, std::array& signature, + std::string& error); + +// Preserves the original author and root ID, while giving the fork a new +// current ID, parent ID, author, creation time and editable state. +bool createSpinoff(const Document& parent, std::string_view newAuthorName, + std::string_view newAuthorFsid, std::string_view newCurrentId, + std::uint64_t nowUnixSeconds, Document& child, + std::string& error); + +} // namespace sppm diff --git a/licenses/THIRD_PARTY_NOTICES.md b/licenses/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..e287a6e --- /dev/null +++ b/licenses/THIRD_PARTY_NOTICES.md @@ -0,0 +1,5 @@ +# Third-party assets + +The Fakenote Studio assets are covered by the MIT licence in `fakenote-studio.txt`. The Bitstream Vera fonts are covered by `bitstream-vera.txt`. + +The original Nintendo DSi Flipnote Studio WAV files are omitted from this source release. The FOT Seurat fonts in `romfs/fonts/` have no redistribution permission documented here. They are not covered by the Fakenote Studio MIT licence. diff --git a/licenses/bitstream-vera.txt b/licenses/bitstream-vera.txt new file mode 100644 index 0000000..e651be1 --- /dev/null +++ b/licenses/bitstream-vera.txt @@ -0,0 +1,124 @@ +Bitstream Vera Fonts Copyright + +The fonts have a generous copyright, allowing derivative works (as +long as "Bitstream" or "Vera" are not in the names), and full +redistribution (so long as they are not *sold* by themselves). They +can be be bundled, redistributed and sold with any software. + +The fonts are distributed under the following copyright: + +Copyright +========= + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream +Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute +the Font Software, including without limitation the rights to use, +copy, merge, publish, distribute, and/or sell copies of the Font +Software, and to permit persons to whom the Font Software is furnished +to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice +shall be included in all copies of one or more of the Font Software +typefaces. + +The Font Software may be modified, altered, or added to, and in +particular the designs of glyphs or characters in the Fonts may be +modified and additional glyphs or characters may be added to the +Fonts, only if the fonts are renamed to names not containing either +the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts +or Font Software that has been modified and is distributed under the +"Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but +no copy of one or more of the Font Software typefaces may be sold by +itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL +BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, +OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT +SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome +Foundation, and Bitstream Inc., shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this Font +Software without prior written authorization from the Gnome Foundation +or Bitstream Inc., respectively. For further information, contact: +fonts at gnome dot org. + +Copyright FAQ +============= + + 1. I don't understand the resale restriction... What gives? + + Bitstream is giving away these fonts, but wishes to ensure its + competitors can't just drop the fonts as is into a font sale system + and sell them as is. It seems fair that if Bitstream can't make money + from the Bitstream Vera fonts, their competitors should not be able to + do so either. You can sell the fonts as part of any software package, + however. + + 2. I want to package these fonts separately for distribution and + sale as part of a larger software package or system. Can I do so? + + Yes. A RPM or Debian package is a "larger software package" to begin + with, and you aren't selling them independently by themselves. + See 1. above. + + 3. Are derivative works allowed? + Yes! + + 4. Can I change or add to the font(s)? + Yes, but you must change the name(s) of the font(s). + + 5. Under what terms are derivative works allowed? + + You must change the name(s) of the fonts. This is to ensure the + quality of the fonts, both to protect Bitstream and Gnome. We want to + ensure that if an application has opened a font specifically of these + names, it gets what it expects (though of course, using fontconfig, + substitutions could still could have occurred during font + opening). You must include the Bitstream copyright. Additional + copyrights can be added, as per copyright law. Happy Font Hacking! + + 6. If I have improvements for Bitstream Vera, is it possible they might get + adopted in future versions? + + Yes. The contract between the Gnome Foundation and Bitstream has + provisions for working with Bitstream to ensure quality additions to + the Bitstream Vera font family. Please contact us if you have such + additions. Note, that in general, we will want such additions for the + entire family, not just a single font, and that you'll have to keep + both Gnome and Jim Lyles, Vera's designer, happy! To make sense to add + glyphs to the font, they must be stylistically in keeping with Vera's + design. Vera cannot become a "ransom note" font. Jim Lyles will be + providing a document describing the design elements used in Vera, as a + guide and aid for people interested in contributing to Vera. + + 7. I want to sell a software package that uses these fonts: Can I do so? + + Sure. Bundle the fonts with your software and sell your software + with the fonts. That is the intent of the copyright. + + 8. If applications have built the names "Bitstream Vera" into them, + can I override this somehow to use fonts of my choosing? + + This depends on exact details of the software. Most open source + systems and software (e.g., Gnome, KDE, etc.) are now converting to + use fontconfig (see www.fontconfig.org) to handle font configuration, + selection and substitution; it has provisions for overriding font + names and subsituting alternatives. An example is provided by the + supplied local.conf file, which chooses the family Bitstream Vera for + "sans", "serif" and "monospace". Other software (e.g., the XFree86 + core server) has other mechanisms for font substitution. + diff --git a/licenses/fakenote-studio.txt b/licenses/fakenote-studio.txt new file mode 100644 index 0000000..7eb59f0 --- /dev/null +++ b/licenses/fakenote-studio.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 jaames + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/licenses/sound-manifest.json b/licenses/sound-manifest.json new file mode 100644 index 0000000..4fbd86d --- /dev/null +++ b/licenses/sound-manifest.json @@ -0,0 +1,33 @@ +{ + "sourceArchive": "SEQARC_SYSTEM_SE 2.zip", + "sourceDirectory": "SEQARC_SYSTEM_SE/", + "conversion": "RIFF PCM stereo 16-bit 65456 Hz to RIFF PCM stereo 16-bit 48000 Hz using FFmpeg; original duration preserved", + "cataloguedWavCount": 143, + "selectionPolicy": "The archive was reviewed by filename and decoded duration. Short interaction cues are mapped by action; long pen/eraser, recording, waiting, and tutorial/music clips are not used as button sounds.", + "effects": { + "button_on": "SE_SY_BUTTON_ON.wav", + "button_off": "SE_SY_BUTTON_OFF.wav", + "cursor_cancel": "SE_SY_CURSOR_CANCEL.wav", + "undo": "SE_SY_UNDO.wav", + "redo": "SE_SY_REDO.wav", + "page_new": "SE_SY_EDIT_PAGE_INSERT.wav", + "movie_play": "SE_SY_MOVIE_PLAY.wav", + "movie_stop": "SE_SY_MOVIE_STOP.wav", + "draw_tool_btn_on": "SE_SY_DRAW_TOOL_BTN_ON.wav", + "folder_open": "SE_SY_FOLDER_OPEN.wav", + "pic_stamp": "SE_SY_PIC_STAMP.wav", + "paint": "SE_SY_PAINT.wav", + "page_backward": "SE_SY_PAGE_BACKWARD.wav", + "page_forward": "SE_SY_PAGE_FORWARD.wav", + "page_first": "SE_SY_PAGE_FIRST.wav", + "page_end": "SE_SY_PAGE_END.wav", + "edit_page_copy": "SE_SY_EDIT_PAGE_COPY.wav", + "edit_page_paste": "SE_SY_EDIT_PAGE_PASTE.wav", + "edit_page_remove": "SE_SY_EDIT_PAGE_REMOVE.wav", + "screen_swap": "SE_SY_SCREEN_SWAP.wav", + "popup": "SE_SY_POPUP.wav", + "popup_select": "SE_SY_POPUP_SELECT.wav", + "save_ok": "SE_SY_SAVE_OK.wav", + "delete_complete": "SE_SY_DELETE_COMPLETE.wav" + } +} diff --git a/paper-stack-transparent.png b/paper-stack-transparent.png new file mode 100644 index 0000000..693fb15 Binary files /dev/null and b/paper-stack-transparent.png differ diff --git a/source/audio_upload_server.cpp b/source/audio_upload_server.cpp new file mode 100644 index 0000000..c43ebb1 --- /dev/null +++ b/source/audio_upload_server.cpp @@ -0,0 +1,701 @@ +#include "audio_upload_server.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __SWITCH__ +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace { + +#ifdef __SWITCH__ +constexpr std::uint16_t kUploadPort = 8766; +constexpr std::size_t kMaxHeaderBytes = 16 * 1024; +constexpr std::size_t kMaxBgmUploadBytes = 32 * 1024 * 1024; +constexpr std::size_t kMaxSfxUploadBytes = 3 * 1024 * 1024; + +struct ServerState { + Thread thread{}; + std::atomic running{false}; + std::atomic connected{false}; + std::atomic pendingTrack{-1}; + std::atomic uploadOutcome{0}; // 0 idle, 1 processing, 2 imported, 3 rejected + std::atomic activeClient{-1}; + bool photoTransfer = false; + int listenFd = -1; + std::string bindAddress; + std::string uploadDirectory; + std::string assetsRoot; + std::string noteSingular = "Switchnote"; + std::string sessionCode; + std::string sessionToken; + std::uint32_t pairedAddress = 0; + unsigned int failedCodeAttempts = 0; +}; + +constexpr unsigned int kMaxCodeAttempts = 8; + +bool validPairCode(const std::string& code) { + if (code.size() != 7 || code[3] != '-') return false; + for (std::size_t i = 0; i < code.size(); ++i) + if (i != 3 && (code[i] < '0' || code[i] > '9')) return false; + return true; +} + +std::string hexToken(const std::array& bytes) { + constexpr char digits[] = "0123456789abcdef"; + std::string token; + token.reserve(bytes.size() * 2); + for (const std::uint8_t byte : bytes) { + token.push_back(digits[byte >> 4]); + token.push_back(digits[byte & 15]); + } + return token; +} + +std::string trim(std::string value) { + while (!value.empty() && (value.back() == '\r' || value.back() == '\n' || + value.back() == ' ' || value.back() == '\t')) value.pop_back(); + std::size_t first = 0; + while (first < value.size() && (value[first] == ' ' || value[first] == '\t')) ++first; + return value.substr(first); +} + +std::string lower(std::string value) { + for (char& c : value) c = static_cast(std::tolower(static_cast(c))); + return value; +} + +bool constantTimeEqual(const std::string& a, const std::string& b) { + const std::size_t length = std::max(a.size(), b.size()); + unsigned int difference = static_cast(a.size() ^ b.size()); + for (std::size_t i = 0; i < length; ++i) { + const unsigned char av = i < a.size() ? static_cast(a[i]) : 0; + const unsigned char bv = i < b.size() ? static_cast(b[i]) : 0; + difference |= static_cast(av ^ bv); + } + return difference == 0; +} + +bool sendAll(int fd, const void* bytes, std::size_t length) { + const auto* data = static_cast(bytes); + std::size_t sent = 0; + while (sent < length) { + const ssize_t count = ::send(fd, data + sent, length - sent, 0); + if (count <= 0) return false; + sent += static_cast(count); + } + return true; +} + +void respond(int fd, int status, const char* reason, const std::string& type, + const void* body, std::size_t length, bool html = false) { + std::ostringstream headers; + headers << "HTTP/1.1 " << status << ' ' << reason << "\r\n" + << "Content-Type: " << type << "\r\n" + << "Content-Length: " << length << "\r\n" + << "Connection: close\r\n" + << "Cache-Control: no-store\r\n" + << "X-Content-Type-Options: nosniff\r\n" + << "Referrer-Policy: no-referrer\r\n" + << "X-Frame-Options: DENY\r\n"; + if (html) headers << "Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self' blob:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'\r\n"; + headers << "\r\n"; + const std::string header = headers.str(); + sendAll(fd, header.data(), header.size()); + if (length) sendAll(fd, body, length); +} + +void respondText(int fd, int status, const char* reason, const char* body) { + respond(fd, status, reason, "text/plain; charset=utf-8", body, std::strlen(body)); +} + +std::string htmlEscape(const std::string& value) { + std::string escaped; + escaped.reserve(value.size()); + for (const char c : value) { + switch (c) { + case '&': escaped += "&"; break; + case '<': escaped += "<"; break; + case '>': escaped += ">"; break; + case '\"': escaped += """; break; + case '\'': escaped += "'"; break; + default: escaped.push_back(c); break; + } + } + return escaped; +} + +std::string jsEscape(const std::string& value) { + std::string escaped; + escaped.reserve(value.size()); + for (const char c : value) { + if (c == '\\' || c == '\'') escaped.push_back('\\'); + if (c == '\n' || c == '\r') continue; + escaped.push_back(c); + } + return escaped; +} + +std::string brandedPage(std::string page, const std::string& noteSingular) { + const std::string name = noteSingular.empty() ? "Switchnote" : noteSingular; + auto replaceAll = [&](const std::string& from, const std::string& to) { + std::size_t at = 0; + while ((at = page.find(from, at)) != std::string::npos) { + page.replace(at, from.size(), to); + at += to.size(); + } + }; + replaceAll("' ", ""); + replaceAll("'Adding audio to the {{NOTE}}…'", "'Adding audio to the {{NOTE_JS}}…'"); + replaceAll("'Audio added to the {{NOTE}}.'", "'Audio added to the {{NOTE_JS}}.'"); + replaceAll("Photo added to the Switchnote.", "Photo added to the {{NOTE_JS}}."); + replaceAll("{{NOTE}}", htmlEscape(name)); + replaceAll("{{NOTE_JS}}", jsEscape(name)); + return page; +} + +std::string uploadPage(const std::string& noteSingular) { + return brandedPage(R"HTML( + +Audio upload +
+

Enter the code shown in Sound on your Nintendo Switch.

+
Enter all six digits to connect.
+ +
+
+)HTML", noteSingular); +} + +std::string photoPage(const std::string& noteSingular) { + return brandedPage(R"HTML(Photo import

Photo Import

Connect to your {{NOTE}} over local Wi-Fi using the code shown on screen.

Enter all six digits to connect.
)HTML", noteSingular); +} + +bool readAsset(const std::string& path, std::vector& result, const std::string& root) { + std::string relative; + if (path == "/assets/font") relative = "/fonts/FOT-Seurat.otf"; + else return false; + std::ifstream file(root + relative, std::ios::binary | std::ios::ate); + if (!file) return false; + const std::streamoff length = file.tellg(); + if (length <= 0 || length > 4 * 1024 * 1024) return false; + result.resize(static_cast(length)); + file.seekg(0, std::ios::beg); + return static_cast(file.read(result.data(), length)); +} + +bool receiveHeaders(int fd, std::string& request, std::size_t& bodyStart) { + char buffer[4096]; + while (true) { + const std::size_t split = request.find("\r\n\r\n"); + if (split != std::string::npos) { bodyStart = split + 4; return true; } + if (request.size() >= kMaxHeaderBytes) return false; + const ssize_t count = ::recv(fd, buffer, sizeof(buffer), 0); + if (count <= 0) return false; + request.append(buffer, static_cast(count)); + if (request.size() > kMaxHeaderBytes + 4096) return false; + } +} + +bool storeUpload(ServerState* state, int fd, int track, const std::string& request, + std::size_t bodyStart, std::size_t bodySize) { + const std::string base = state->uploadDirectory + "/audio-transfer-" + std::to_string(track); + const std::string temporary = base + ".part"; + const std::string destination = base + ".wav"; + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + if (!output) { + respondText(fd, 500, "Internal Server Error", "Could not save the upload."); + return false; + } + const std::size_t inBuffer = std::min(bodySize, request.size() - bodyStart); + char wavHeader[12]{}; + std::size_t headerCount = 0; + auto append = [&](const char* bytes, std::size_t count) { + const std::size_t prefix = std::min(count, sizeof(wavHeader) - headerCount); + if (prefix) { std::memcpy(wavHeader + headerCount, bytes, prefix); headerCount += prefix; } + output.write(bytes, static_cast(count)); + }; + append(request.data() + bodyStart, inBuffer); + std::size_t received = inBuffer; + char buffer[8192]; + while (output && received < bodySize && state->running.load(std::memory_order_acquire)) { + const std::size_t needed = std::min(sizeof(buffer), bodySize - received); + const ssize_t count = ::recv(fd, buffer, needed, 0); + if (count <= 0) break; + append(buffer, static_cast(count)); + received += static_cast(count); + } + if (!output || received != bodySize) { + output.close(); + std::remove(temporary.c_str()); + respondText(fd, 400, "Bad Request", "Upload was interrupted."); + return false; + } + if (std::memcmp(wavHeader, "RIFF", 4) != 0 || std::memcmp(wavHeader + 8, "WAVE", 4) != 0) { + output.close(); + std::remove(temporary.c_str()); + respondText(fd, 415, "Unsupported Media Type", "This file is not a valid WAV container."); + return false; + } + output.flush(); + output.close(); + // This path is reserved for the transient PC transfer. A previous session + // can leave it behind if the app was closed during import. + std::remove(destination.c_str()); + if (!output || std::rename(temporary.c_str(), destination.c_str()) != 0) { + std::remove(temporary.c_str()); + respondText(fd, 500, "Internal Server Error", "Could not finalize the upload."); + return false; + } + state->uploadOutcome.store(1, std::memory_order_release); + state->pendingTrack.store(track, std::memory_order_release); + const char* success = "WAV received by Switch. Importing now."; + respond(fd, 200, "OK", "text/plain; charset=utf-8", success, std::strlen(success)); + return true; +} + +bool storePhotoUpload(ServerState* state, int fd, const std::string& request, + std::size_t bodyStart, std::size_t bodySize, + const std::map& headers) { + auto number = [&](const char* key, int low, int high, int& value) { + const auto found = headers.find(key); + if (found == headers.end() || found->second.empty()) return false; + char* end = nullptr; + const long parsed = std::strtol(found->second.c_str(), &end, 10); + if (!end || *end || parsed < low || parsed > high) return false; + value = static_cast(parsed); + return true; + }; + int layer = 0, size = 0, offsetX = 0, offsetY = 0; + if (headers.find("x-photo-mode") == headers.end() || headers.find("x-photo-dither") == headers.end() || + !number("x-photo-layer", 0, 3, layer) || !number("x-photo-size", 25, 200, size) || + !number("x-photo-x", -300, 300, offsetX) || !number("x-photo-y", -180, 180, offsetY) || + (headers.at("x-photo-mode") != "color" && headers.at("x-photo-mode") != "ink") || + (headers.at("x-photo-dither") != "none" && headers.at("x-photo-dither") != "ordered")) { + respondText(fd, 400, "Bad Request", "Photo options are invalid."); + return false; + } + constexpr std::size_t expectedBytes = 600U * 360U * 4U; + if (bodySize != expectedBytes || request.size() < bodyStart || request.size() - bodyStart > bodySize) { + respondText(fd, 400, "Bad Request", "Processed photo has an invalid canvas size."); + return false; + } + const std::string path = state->uploadDirectory + "/photo-transfer.rgba"; + const std::string configPath = state->uploadDirectory + "/photo-transfer.cfg"; + std::ofstream output(path, std::ios::binary | std::ios::trunc); + if (!output) { respondText(fd, 500, "Internal Server Error", "Could not open the photo transfer file."); return false; } + const std::size_t buffered = std::min(bodySize, request.size() - bodyStart); + output.write(request.data() + bodyStart, static_cast(buffered)); + std::size_t received = buffered; + char buffer[8192]; + while (output && received < bodySize && state->running.load(std::memory_order_acquire)) { + const ssize_t count = ::recv(fd, buffer, std::min(sizeof(buffer), bodySize - received), 0); + if (count <= 0) break; + output.write(buffer, count); + received += static_cast(count); + } + output.flush(); + output.close(); + if (!output || received != bodySize) { std::remove(path.c_str()); respondText(fd, 400, "Bad Request", "Photo upload was interrupted."); return false; } + std::ofstream config(configPath, std::ios::binary | std::ios::trunc); + config << layer << '\n' << headers.at("x-photo-mode") << '\n' << headers.at("x-photo-dither") + << '\n' << size << '\n' << offsetX << '\n' << offsetY << '\n'; + config.flush(); + if (!config) { config.close(); std::remove(path.c_str()); respondText(fd, 500, "Internal Server Error", "Could not save photo options."); return false; } + state->uploadOutcome.store(1, std::memory_order_release); + state->pendingTrack.store(4, std::memory_order_release); + const char* success = "Photo received by Switch. Importing now."; + respond(fd, 200, "OK", "text/plain; charset=utf-8", success, std::strlen(success)); + return true; +} + +void serveClient(ServerState* state, int fd, std::uint32_t clientAddress) { + timeval timeout{}; + timeout.tv_sec = 4; + ::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + ::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + std::string request; + std::size_t bodyStart = 0; + if (!receiveHeaders(fd, request, bodyStart)) { + respondText(fd, 400, "Bad Request", "Invalid HTTP request."); + return; + } + const std::size_t lineEnd = request.find("\r\n"); + if (lineEnd == std::string::npos) return; + std::istringstream firstLine(request.substr(0, lineEnd)); + std::string method, path, version; + firstLine >> method >> path >> version; + if (version != "HTTP/1.1" && version != "HTTP/1.0") { + respondText(fd, 400, "Bad Request", "Invalid HTTP version."); + return; + } + std::map headers; + std::size_t cursor = lineEnd + 2; + while (cursor < bodyStart - 2) { + const std::size_t end = request.find("\r\n", cursor); + if (end == std::string::npos || end == cursor) break; + const std::size_t colon = request.find(':', cursor); + if (colon == std::string::npos || colon >= end) { + respondText(fd, 400, "Bad Request", "Malformed request header."); + return; + } + headers[lower(trim(request.substr(cursor, colon - cursor)))] = trim(request.substr(colon + 1, end - colon - 1)); + cursor = end + 2; + } + + if (method == "GET" && path == "/") { + const std::string page = state->photoTransfer ? photoPage(state->noteSingular) : uploadPage(state->noteSingular); + respond(fd, 200, "OK", "text/html; charset=utf-8", page.data(), page.size(), true); + return; + } + if (method == "GET" && path == "/assets/font") { + std::vector asset; + if (!readAsset(path, asset, state->assetsRoot)) { + respondText(fd, 404, "Not Found", "Asset not found."); + return; + } + respond(fd, 200, "OK", "font/otf", asset.data(), asset.size()); + return; + } + const bool pairing = method == "POST" && path == "/api/connect"; + const bool photoUpload = state->photoTransfer && method == "POST" && path == "/api/photo"; + if (!pairing && !photoUpload && !((method == "POST" && path == "/api/upload") || (method == "GET" && path == "/api/status"))) { + respondText(fd, 404, "Not Found", "Not found."); + return; + } + const std::string auth = headers["authorization"]; + if (pairing) { + if (state->connected.load(std::memory_order_acquire) && clientAddress != state->pairedAddress) { + respondText(fd, 409, "Conflict", "Another PC is already connected. Reopen Sound to pair again."); + return; + } + if (state->failedCodeAttempts >= kMaxCodeAttempts) { + respondText(fd, 429, "Too Many Requests", "Too many incorrect codes. Reopen Sound for a new code."); + return; + } + if (!constantTimeEqual(auth, "Bearer " + state->sessionCode)) { + ++state->failedCodeAttempts; + respondText(fd, 403, "Forbidden", "Connection code is incorrect."); + return; + } + state->pairedAddress = clientAddress; + state->connected.store(true, std::memory_order_release); + respond(fd, 200, "OK", "text/plain; charset=utf-8", + state->sessionToken.data(), state->sessionToken.size()); + return; + } + if (!state->connected.load(std::memory_order_acquire) || clientAddress != state->pairedAddress || + !constantTimeEqual(auth, "Bearer " + state->sessionToken)) { + respondText(fd, 403, "Forbidden", "Connect to the Switch first."); + return; + } + if (method == "GET") { + const int outcome = state->uploadOutcome.load(std::memory_order_acquire); + respondText(fd, 200, "OK", outcome == 2 ? "imported" : outcome == 3 ? "rejected" : "processing"); + return; + } + if (state->pendingTrack.load(std::memory_order_acquire) >= 0) { + respondText(fd, 409, "Conflict", "The previous upload is still being added on the Switch."); + return; + } + if (headers.find("transfer-encoding") != headers.end()) { + respondText(fd, 400, "Bad Request", "Chunked upload is not supported."); + return; + } + char* end = nullptr; + const std::string lengthHeader = headers["content-length"]; + const unsigned long long bodyLength = std::strtoull(lengthHeader.c_str(), &end, 10); + if (photoUpload) { + constexpr unsigned long long photoBytes = 600ULL * 360ULL * 4ULL; + if (lengthHeader.empty() || !end || *end != '\0' || bodyLength != photoBytes || + lower(headers["content-type"]) != "application/octet-stream") { + respondText(fd, 413, "Payload Too Large", "Send a processed 600 by 360 RGBA photo."); + return; + } + storePhotoUpload(state, fd, request, bodyStart, static_cast(bodyLength), headers); + return; + } + if (lengthHeader.empty() || !end || *end != '\0' || bodyLength < 12 || bodyLength > kMaxBgmUploadBytes) { + respondText(fd, 413, "Payload Too Large", "Choose a WAV under 32 MiB."); + return; + } + const std::string trackHeader = headers["x-audio-track"]; + if (trackHeader.size() != 1 || trackHeader[0] < '0' || trackHeader[0] > '3') { + respondText(fd, 400, "Bad Request", "Choose BGM or one of SE1-SE3."); + return; + } + const int track = trackHeader[0] - '0'; + if (track > 0 && bodyLength > kMaxSfxUploadBytes) { + respondText(fd, 413, "Payload Too Large", "A sound-effect WAV must be under 3 MiB."); + return; + } + const std::string contentType = lower(headers["content-type"]); + if (contentType != "audio/wav" && contentType != "audio/x-wav" && contentType != "application/octet-stream") { + respondText(fd, 415, "Unsupported Media Type", "Select a WAV audio file."); + return; + } + storeUpload(state, fd, track, request, bodyStart, static_cast(bodyLength)); +} + +void serverThread(void* argument) { + auto* state = static_cast(argument); + while (state->running.load(std::memory_order_acquire)) { + fd_set readers; + FD_ZERO(&readers); + FD_SET(state->listenFd, &readers); + timeval wait{}; + wait.tv_sec = 0; + wait.tv_usec = 100000; + const int ready = ::select(state->listenFd + 1, &readers, nullptr, nullptr, &wait); + if (ready <= 0) continue; + if (!state->running.load(std::memory_order_acquire)) break; + sockaddr_in peer{}; + socklen_t peerSize = sizeof(peer); + const int client = ::accept(state->listenFd, reinterpret_cast(&peer), &peerSize); + if (client < 0) continue; + state->activeClient.store(client, std::memory_order_release); + serveClient(state, client, peer.sin_addr.s_addr); + ::shutdown(client, SHUT_RDWR); + ::close(client); + state->activeClient.store(-1, std::memory_order_release); + } +} +#endif + +} // namespace + +AudioUploadServer::AudioUploadServer() = default; + +AudioUploadServer::~AudioUploadServer() { stop(); } + +bool AudioUploadServer::start(const std::string& bindAddress, const std::string& uploadDirectory, + const std::string& assetsRoot, const std::string& sessionCode, + std::string& error, bool photoTransfer, + const std::string& noteSingular) { + stop(); +#ifdef __SWITCH__ + if (!validPairCode(sessionCode) || uploadDirectory.empty()) { + error = "Invalid upload server configuration."; + return false; + } + auto* state = new (std::nothrow) ServerState{}; + if (!state) { error = "Not enough memory to open the sound server."; return false; } + state->bindAddress = bindAddress; + state->uploadDirectory = uploadDirectory; + state->assetsRoot = assetsRoot; + state->noteSingular = noteSingular; + state->sessionCode = sessionCode; + state->photoTransfer = photoTransfer; + std::array tokenBytes{}; + if (R_FAILED(csrngGetRandomBytes(tokenBytes.data(), tokenBytes.size()))) { + delete state; + error = "Could not create a secure upload session."; + return false; + } + state->sessionToken = hexToken(tokenBytes); + state->listenFd = ::socket(AF_INET, SOCK_STREAM, 0); + if (state->listenFd < 0) { delete state; error = "Could not create the upload socket."; return false; } + int reuse = 1; + ::setsockopt(state->listenFd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_port = htons(kUploadPort); + if (::inet_pton(AF_INET, bindAddress.c_str(), &address.sin_addr) != 1 || + ::bind(state->listenFd, reinterpret_cast(&address), sizeof(address)) != 0 || + ::listen(state->listenFd, 2) != 0) { + ::close(state->listenFd); + delete state; + error = "Could not listen on " + bindAddress + ":" + std::to_string(kUploadPort) + "."; + return false; + } + state->running.store(true, std::memory_order_release); + const Result created = threadCreate(&state->thread, serverThread, state, nullptr, 0x10000, 0x3B, -2); + if (R_FAILED(created)) { + state->running.store(false, std::memory_order_release); + ::close(state->listenFd); + delete state; + error = "Could not start the upload service thread."; + return false; + } + if (R_FAILED(threadStart(&state->thread))) { + state->running.store(false, std::memory_order_release); + ::close(state->listenFd); + threadClose(&state->thread); + delete state; + error = "Could not start the upload service thread."; + return false; + } + impl_ = state; + error.clear(); + return true; +#else + (void)bindAddress; (void)uploadDirectory; (void)assetsRoot; (void)sessionCode; (void)photoTransfer; (void)noteSingular; + error = "The Switch upload server is available in the homebrew app."; + return false; +#endif +} + +void AudioUploadServer::stop() { +#ifdef __SWITCH__ + auto* state = static_cast(impl_); + if (!state) return; + state->running.store(false, std::memory_order_release); + const int client = state->activeClient.load(std::memory_order_acquire); + if (client >= 0) ::shutdown(client, SHUT_RDWR); + threadWaitForExit(&state->thread); + if (state->listenFd >= 0) ::close(state->listenFd); + threadClose(&state->thread); + delete state; + impl_ = nullptr; +#endif +} + +bool AudioUploadServer::isRunning() const { +#ifdef __SWITCH__ + const auto* state = static_cast(impl_); + return state && state->running.load(std::memory_order_acquire); +#else + return false; +#endif +} + +bool AudioUploadServer::isConnected() const { +#ifdef __SWITCH__ + const auto* state = static_cast(impl_); + return state && state->connected.load(std::memory_order_acquire); +#else + return false; +#endif +} + +bool AudioUploadServer::takeCompletedUpload(int& track) { +#ifdef __SWITCH__ + auto* state = static_cast(impl_); + if (!state) return false; + track = state->pendingTrack.load(std::memory_order_acquire); + return track >= 0 && track <= 4; +#else + (void)track; + return false; +#endif +} + +void AudioUploadServer::finishCompletedUpload(bool success) { +#ifdef __SWITCH__ + auto* state = static_cast(impl_); + if (state) { + state->uploadOutcome.store(success ? 2 : 3, std::memory_order_release); + state->pendingTrack.store(-1, std::memory_order_release); + } +#else + (void)success; +#endif +} diff --git a/source/branding_bundle.cpp b/source/branding_bundle.cpp new file mode 100644 index 0000000..3c55fd3 --- /dev/null +++ b/source/branding_bundle.cpp @@ -0,0 +1,162 @@ +#include "branding_bundle.hpp" + +#include +#include +#include +#include + +namespace branding { +namespace { + +void put16(std::vector& out, std::uint16_t v) { + out.push_back(static_cast(v)); + out.push_back(static_cast(v >> 8)); +} +void put32(std::vector& out, std::uint32_t v) { + put16(out, static_cast(v)); + put16(out, static_cast(v >> 16)); +} +std::uint16_t get16(const std::vector& in, std::size_t p) { + return static_cast(in[p] | (static_cast(in[p + 1]) << 8)); +} +std::uint32_t get32(const std::vector& in, std::size_t p) { + return static_cast(get16(in, p)) | + (static_cast(get16(in, p + 2)) << 16); +} +std::uint32_t crc32(const unsigned char* data, std::size_t size) { + std::uint32_t crc = 0xffffffffU; + for (std::size_t i = 0; i < size; ++i) { + crc ^= data[i]; + for (int bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ (0xedb88320U & (0U - (crc & 1U))); + } + return ~crc; +} +std::string escape(const std::string& value) { + std::string out = "\""; + for (unsigned char c : value) { + if (c == '"' || c == '\\') { out.push_back('\\'); out.push_back(static_cast(c)); } + else if (c < 0x20) { + static constexpr char hex[] = "0123456789abcdef"; + out += "\\u00"; out.push_back(hex[c >> 4]); out.push_back(hex[c & 15]); + } else out.push_back(static_cast(c)); + } + out.push_back('"'); + return out; +} +void appendUtf8(std::string& out, std::uint32_t c) { + if (c <= 0x7f) out.push_back(static_cast(c)); + else if (c <= 0x7ff) { out.push_back(static_cast(0xc0 | (c >> 6))); out.push_back(static_cast(0x80 | (c & 63))); } + else { out.push_back(static_cast(0xe0 | (c >> 12))); out.push_back(static_cast(0x80 | ((c >> 6) & 63))); out.push_back(static_cast(0x80 | (c & 63))); } +} +bool jsonString(const std::string& json, const std::string& key, std::string& value) { + const std::string marker = "\"" + key + "\""; + std::size_t p = json.find(marker); + if (p == std::string::npos) return false; + p = json.find(':', p + marker.size()); + if (p == std::string::npos) return false; + while (++p < json.size() && (json[p] == ' ' || json[p] == '\t' || json[p] == '\r' || json[p] == '\n')) {} + if (p >= json.size() || json[p++] != '"') return false; + value.clear(); + while (p < json.size()) { + unsigned char c = static_cast(json[p++]); + if (c == '"') return true; + if (c != '\\') { value.push_back(static_cast(c)); continue; } + if (p >= json.size()) return false; + c = static_cast(json[p++]); + switch (c) { + case '"': case '\\': case '/': value.push_back(static_cast(c)); break; + case 'b': value.push_back('\b'); break; case 'f': value.push_back('\f'); break; + case 'n': value.push_back('\n'); break; case 'r': value.push_back('\r'); break; case 't': value.push_back('\t'); break; + case 'u': { + if (p + 4 > json.size()) return false; + std::uint32_t cp = 0; + for (int i = 0; i < 4; ++i) { + const char h = json[p++]; cp <<= 4; + if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); + else return false; + } + appendUtf8(value, cp); break; + } + default: return false; + } + } + return false; +} + +} // namespace + +bool writeConfig(const std::string& path, const Bundle& bundle, std::string& error) { + if (bundle.app.empty() || bundle.app.size() > 40 || bundle.singular.empty() || bundle.singular.size() > 24 || + bundle.plural.empty() || bundle.plural.size() > 24 || bundle.logo.empty() || bundle.logo.size() > 8U * 1024U * 1024U) { + error = "Branding values or logo are outside supported limits."; return false; + } + const std::string json = "{\"app\":" + escape(bundle.app) + ",\"singular\":" + escape(bundle.singular) + + ",\"plural\":" + escape(bundle.plural) + "}"; + struct Entry { std::string name; const unsigned char* data; std::uint32_t size; std::uint32_t crc; std::uint32_t offset; }; + const std::string names[2] = {"branding.json", "logo.png"}; + const std::string jsonOwned = json; + const unsigned char* data[2] = {reinterpret_cast(jsonOwned.data()), bundle.logo.data()}; + const std::uint32_t sizes[2] = {static_cast(jsonOwned.size()), static_cast(bundle.logo.size())}; + std::vector archive; + Entry entries[2]{}; + for (int i = 0; i < 2; ++i) { + Entry& e = entries[i]; e.name = names[i]; e.data = data[i]; e.size = sizes[i]; e.crc = crc32(e.data, e.size); e.offset = static_cast(archive.size()); + put32(archive, 0x04034b50U); put16(archive, 20); put16(archive, 0); put16(archive, 0); + put16(archive, 0); put16(archive, 0); put32(archive, e.crc); put32(archive, e.size); put32(archive, e.size); + put16(archive, static_cast(e.name.size())); put16(archive, 0); + archive.insert(archive.end(), e.name.begin(), e.name.end()); archive.insert(archive.end(), e.data, e.data + e.size); + } + const std::uint32_t centralOffset = static_cast(archive.size()); + for (const Entry& e : entries) { + put32(archive, 0x02014b50U); put16(archive, 20); put16(archive, 20); put16(archive, 0); put16(archive, 0); + put16(archive, 0); put16(archive, 0); put32(archive, e.crc); put32(archive, e.size); put32(archive, e.size); + put16(archive, static_cast(e.name.size())); put16(archive, 0); put16(archive, 0); put16(archive, 0); + put16(archive, 0); put32(archive, 0); put32(archive, e.offset); + archive.insert(archive.end(), e.name.begin(), e.name.end()); + } + const std::uint32_t centralSize = static_cast(archive.size()) - centralOffset; + put32(archive, 0x06054b50U); put16(archive, 0); put16(archive, 0); put16(archive, 2); put16(archive, 2); + put32(archive, centralSize); put32(archive, centralOffset); put16(archive, 0); + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) { error = "Could not create branding.config."; return false; } + out.write(reinterpret_cast(archive.data()), static_cast(archive.size())); + out.flush(); + if (!out) { error = "Could not write branding.config."; return false; } + error.clear(); return true; +} + +bool readConfig(const std::string& path, Bundle& bundle, std::string& error) { + std::ifstream in(path, std::ios::binary | std::ios::ate); + if (!in) { error = "Copy a .config file to the Switchnote folder first."; return false; } + const std::streamoff length = in.tellg(); + if (length < 22 || length > 9 * 1024 * 1024) { error = "Branding config size is invalid."; return false; } + std::vector bytes(static_cast(length)); in.seekg(0); in.read(reinterpret_cast(bytes.data()), length); + if (!in) { error = "Could not read branding config."; return false; } + std::string json; std::vector logo; std::size_t p = 0; + while (p + 4 <= bytes.size() && get32(bytes, p) == 0x04034b50U) { + if (p + 30 > bytes.size()) { error = "Branding config archive is truncated."; return false; } + const std::uint16_t flags = get16(bytes, p + 6), method = get16(bytes, p + 8), nameSize = get16(bytes, p + 26), extraSize = get16(bytes, p + 28); + const std::uint32_t expectedCrc = get32(bytes, p + 14), compressed = get32(bytes, p + 18), uncompressed = get32(bytes, p + 22); + const std::size_t nameAt = p + 30, dataAt = nameAt + nameSize + extraSize; + if (flags != 0 || method != 0 || compressed != uncompressed || dataAt > bytes.size() || compressed > bytes.size() - dataAt) { error = "Only standard uncompressed branding.config files are supported."; return false; } + const std::string name(bytes.begin() + static_cast(nameAt), bytes.begin() + static_cast(nameAt + nameSize)); + const auto* data = bytes.data() + dataAt; + if (crc32(data, uncompressed) != expectedCrc) { error = "Branding config file checksum failed."; return false; } + if (name == "branding.json") json.assign(reinterpret_cast(data), uncompressed); + else if (name == "logo.png") logo.assign(data, data + uncompressed); + else if (name.find('/') != std::string::npos || name.find('\\') != std::string::npos || name == "..") { error = "Branding config contains an unsafe file path."; return false; } + p = dataAt + compressed; + } + if (json.empty() || logo.empty() || logo.size() > 8U * 1024U * 1024U || + !jsonString(json, "app", bundle.app) || !jsonString(json, "singular", bundle.singular) || !jsonString(json, "plural", bundle.plural) || + bundle.app.empty() || bundle.app.size() > 40 || bundle.singular.empty() || bundle.singular.size() > 24 || bundle.plural.empty() || bundle.plural.size() > 24) { + error = "Branding config is missing valid names or a logo."; return false; + } + bundle.logo = std::move(logo); + error.clear(); return true; +} + +} // namespace branding diff --git a/source/branding_config_server.cpp b/source/branding_config_server.cpp new file mode 100644 index 0000000..2e61b8d --- /dev/null +++ b/source/branding_config_server.cpp @@ -0,0 +1,232 @@ +#include "branding_config_server.hpp" +#include "branding_bundle.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __SWITCH__ +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace { +#ifdef __SWITCH__ +constexpr std::uint16_t kPort = 8767; +constexpr std::size_t kMaxRequest = 8 * 1024 * 1024 + 16 * 1024; +std::string portalAppName = "Switchnote Studio"; +std::string portalNoteSingular = "Switchnote"; +std::string portalNotePlural = "Switchnotes"; +struct State { + Thread thread{}; + std::atomic running{false}; + std::atomic connected{false}; + std::atomic pending{false}; + std::atomic lastSeenSeconds{0}; + std::atomic activeClient{-1}; + int listenFd = -1; + std::string bindAddress, dataDirectory, assetsRoot, code, token; + std::uint32_t pairedAddress = 0; + unsigned int failedCodeAttempts = 0; +}; + +void logEvent(const State* st, const std::string& message) { + std::ofstream log(st->dataDirectory + "/branding-server.log", std::ios::app); + if (log) log << static_cast(std::time(nullptr)) << " " << message << '\n'; +} + +bool equalToken(const std::string& a, const std::string& b) { + unsigned int diff = static_cast(a.size() ^ b.size()); + for (std::size_t i = 0; i < std::max(a.size(), b.size()); ++i) + diff |= static_cast((i < a.size() ? a[i] : 0) ^ (i < b.size() ? b[i] : 0)); + return diff == 0; +} +bool sendAll(int fd, const char* data, std::size_t size) { + std::size_t sent = 0; + while (sent < size) { const ssize_t n = ::send(fd, data + sent, size - sent, 0); if (n <= 0) return false; sent += static_cast(n); } + return true; +} +void reply(int fd, int status, const char* reason, const std::string& type, const std::string& body) { + std::ostringstream h; h << "HTTP/1.1 " << status << ' ' << reason << "\r\nContent-Type: " << type + << "\r\nContent-Length: " << body.size() << "\r\nConnection: close\r\nCache-Control: no-store\r\n" + << "X-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nX-Frame-Options: DENY\r\n" + << "Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self' blob:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'\r\n\r\n"; + const std::string headers = h.str(); sendAll(fd, headers.data(), headers.size()); if (!body.empty()) sendAll(fd, body.data(), body.size()); +} +void textReply(int fd, int status, const char* reason, const std::string& body) { reply(fd, status, reason, "text/plain; charset=utf-8", body); } +std::string htmlEscape(const std::string& value) { + std::string out; + out.reserve(value.size()); + for (char c : value) { + switch (c) { + case '&': out += "&"; break; + case '<': out += "<"; break; + case '>': out += ">"; break; + case '\"': out += """; break; + case '\'': out += "'"; break; + default: out += c; break; + } + } + return out; +} +std::string page() { + std::string html = R"HTML(Branding Config Portal

Branding Config Portal

Enter the code shown on your Nintendo Switch.

Enter all six digits to connect.
)HTML"; + auto replace = [&](const std::string& marker, const std::string& value) { + const std::size_t at = html.find(marker); + if (at != std::string::npos) html.replace(at, marker.size(), htmlEscape(value)); + }; + replace("{{APP_NAME}}", portalAppName); + replace("{{NOTE_NAME}}", portalNoteSingular); + replace("{{NOTES_NAME}}", portalNotePlural); + const std::string loaderInput = "Animated images are reduced to eight small frames for the Switch."; + const std::size_t formEnd = html.find(""); + if (formEnd != std::string::npos) html.insert(formEnd, loaderInput); + const std::string bundleControls = "
Branding file

Save or load your branding and title logo as a .config file.

"; + const std::size_t bundleFormEnd = html.find(""); + if (bundleFormEnd != std::string::npos) html.insert(bundleFormEnd, bundleControls); + const std::string uploadScript = R"JS( +async function uploadLoading(file){if(!file)return;if(!['image/gif','image/webp'].includes(file.type))throw Error('Choose an animated GIF or WebP.');if(file.size>16*1024*1024)throw Error('Loading animation must be 16 MB or smaller.');const url=URL.createObjectURL(file),im=new Image();try{await new Promise((resolve,reject)=>{im.onload=resolve;im.onerror=()=>reject(Error('Could not decode that animation.'));im.src=url});const c=document.createElement('canvas');c.width=64;c.height=64;const g=c.getContext('2d');for(let i=0;i<8;i++){g.clearRect(0,0,64,64);const s=Math.min(64/im.naturalWidth,64/im.naturalHeight),w=im.naturalWidth*s,h=im.naturalHeight*s;g.drawImage(im,(64-w)/2,(64-h)/2,w,h);const png=await new Promise(resolve=>c.toBlob(resolve,'image/png'));if(!png)throw Error('Could not convert the animation.');const r=await fetch('/api/loading/'+i,{method:'PUT',headers:{Authorization:'Bearer '+token,'Content-Type':'image/png'},body:png});if(!r.ok)throw Error(await r.text()||'Could not upload the loading animation.');await new Promise(resolve=>setTimeout(resolve,125))}}finally{URL.revokeObjectURL(url)}} +)JS"; + const std::string bundleScript = R"JS( +async function exportBrandingBundle(){if(!token||busy)return;busy=true;$('exportBundle').disabled=true;try{msg('Preparing branding file…');const r=await fetch('/api/bundle',{headers:{Authorization:'Bearer '+token},cache:'no-store'});if(!r.ok)throw Error(await r.text()||'Could not export branding.');const blob=await r.blob(),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='branding.config';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);msg('Branding exported as branding.config.','ok')}catch(e){msg(e.message||'Could not export branding.','error')}finally{busy=false;$('exportBundle').disabled=false}} +async function importBrandingBundle(file){if(!file||!token||busy)return;busy=true;$('chooseBundle').disabled=true;try{if(file.size>8*1024*1024+16384)throw Error('Branding file is too large.');msg('Importing branding…');const r=await fetch('/api/bundle',{method:'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/octet-stream'},body:file});if(!r.ok)throw Error(await r.text()||'Could not import branding.');const current=await fetch('/api/config',{headers:{Authorization:'Bearer '+token},cache:'no-store'});if(current.ok){const saved=await current.json();$('app').value=saved.app;$('singular').value=saved.singular;$('plural').value=saved.plural}$('logo').value='';msg('Branding imported. Restart the app for changes to apply.','ok')}catch(e){msg(e.message||'Could not import branding.','error')}finally{busy=false;$('chooseBundle').disabled=false;$('bundleFile').value=''}} +$('exportBundle').addEventListener('click',exportBrandingBundle);$('chooseBundle').addEventListener('click',()=>{if(!busy)$('bundleFile').click()});$('bundleFile').addEventListener('change',()=>importBrandingBundle($('bundleFile').files[0])); +)JS"; + const std::string submitHook = "$('form').addEventListener('submit',async e=>{e.preventDefault();if(!token||busy)return;busy=true;$('save').disabled=true;try{const f=$('logo').files[0];"; + const std::string hookedSubmit = "$('form').addEventListener('submit',async e=>{e.preventDefault();if(!token||busy)return;busy=true;$('save').disabled=true;try{const loadingFile=$('loading').files[0];if(loadingFile)await uploadLoading(loadingFile);const f=$('logo').files[0];"; + const std::size_t submitAt = html.find(submitHook); + if (submitAt != std::string::npos) html.replace(submitAt, submitHook.size(), hookedSubmit); + const std::size_t scriptAt = html.find("setInterval(pollConnection,2000);"); + if (scriptAt != std::string::npos) html.insert(scriptAt, uploadScript); + const std::size_t bundleScriptAt = html.find("setInterval(pollConnection,2000);"); + if (bundleScriptAt != std::string::npos) html.insert(bundleScriptAt, bundleScript); + return html; +} +bool readRequest(int fd, std::string& request, std::size_t& bodyAt, std::size_t& bodySize) { + char buffer[8192]; std::size_t headerEnd = std::string::npos; + while (headerEnd == std::string::npos) { ssize_t n=::recv(fd,buffer,sizeof(buffer),0); if(n<=0)return false; request.append(buffer,static_cast(n)); if(request.size()>16384)return false; headerEnd=request.find("\r\n\r\n"); } + bodyAt=headerEnd+4; std::string headers=request.substr(0,headerEnd); std::size_t pos=headers.find("Content-Length:"); + bodySize=0;if(pos!=std::string::npos){pos+=15;while(pos(std::strtoul(headers.c_str()+pos,nullptr,10));} + if(bodySize>kMaxRequest)return false; + while(request.size()-bodyAt(got));} + return true; +} +std::string decode(const std::string& s) { std::string o; for(std::size_t i=0;i(std::strtoul(h,nullptr,16));i+=2;}else o+=s[i];}return o; } +std::string jsonEscape(const std::string& s){std::string o="\"";for(unsigned char c:s){if(c=='\\'||c=='\"'){o+='\\';o+=static_cast(c);}else if(c<32){constexpr char h[]="0123456789abcdef";o+="\\u00";o+=h[c>>4];o+=h[c&15];}else o+=static_cast(c);}o+='\"';return o;} +std::map fields(const std::string& b){std::map m;std::size_t p=0;while(p<=b.size()){auto e=b.find('&',p);auto part=b.substr(p,e==std::string::npos?e:e-p);auto q=part.find('=');if(q!=std::string::npos)m[decode(part.substr(0,q))]=decode(part.substr(q+1));if(e==std::string::npos)break;p=e+1;}return m;} +bool cleanValue(std::string& s, std::size_t max) { if(s.empty()||s.size()>max)return false;for(unsigned char c:s)if(c<32||c==127)return false;return true; } +void handle(State* st,int fd){std::string req;std::size_t bodyAt=0,bodySize=0;if(!readRequest(fd,req,bodyAt,bodySize)){textReply(fd,400,"Bad Request","Invalid request.");return;}auto lineEnd=req.find("\r\n");if(lineEnd==std::string::npos)return;std::istringstream first(req.substr(0,lineEnd));std::string method,path,version;first>>method>>path>>version;std::string headers=req.substr(lineEnd+2,bodyAt-lineEnd-4);auto h=headers.find("Authorization: Bearer ");std::string auth=h==std::string::npos?"":headers.substr(h+22);auto trimEnd=auth.find("\r\n");if(trimEnd!=std::string::npos)auth.resize(trimEnd);auto peer=static_cast(st->pairedAddress);sockaddr_in addr{};socklen_t addrLen=sizeof(addr);if(getpeername(fd,reinterpret_cast(&addr),&addrLen)==0)peer=ntohl(addr.sin_addr.s_addr); +if(method=="GET"&&path=="/"){reply(fd,200,"OK","text/html; charset=utf-8",page());return;}if(method=="GET"&&path=="/favicon.ico"){reply(fd,204,"No Content","image/x-icon","");return;}if(method=="GET"&&path=="/api/health"){textReply(fd,200,"OK","Branding portal build: save-diagnostics-1");return;}if(method=="GET"&&path=="/assets/font"){std::ifstream f(st->assetsRoot+"/fonts/FOT-Seurat.otf",std::ios::binary|std::ios::ate);if(!f){textReply(fd,404,"Not Found","Font unavailable.");return;}auto n=f.tellg();if(n<=0||n>4*1024*1024){textReply(fd,404,"Not Found","Font unavailable.");return;}std::string data(static_cast(n),'\0');f.seekg(0);f.read(data.data(),n);reply(fd,200,"OK","font/otf",data);return;} +if(method=="POST"&&path=="/api/connect"){if(st->failedCodeAttempts>=8){textReply(fd,429,"Too Many Requests","Close and reopen the portal on Switch to try again.");return;}std::string code=auth;code.erase(std::remove(code.begin(),code.end(),'"'),code.end());if(!equalToken(code,st->code)){++st->failedCodeAttempts;logEvent(st,"pairing rejected");textReply(fd,403,"Forbidden","Incorrect code.");return;}st->pairedAddress=peer;std::array random{};if(R_FAILED(csrngGetRandomBytes(random.data(),random.size()))){textReply(fd,500,"Internal Server Error","Could not create session.");return;}constexpr char hex[]="0123456789abcdef";st->token.clear();for(auto b:random){st->token+=hex[b>>4];st->token+=hex[b&15];}st->lastSeenSeconds.store(static_cast(std::time(nullptr)));st->connected.store(true);logEvent(st,"paired PC session");textReply(fd,200,"OK",st->token);return;} +if(!st->connected.load()||peer!=st->pairedAddress||!equalToken(auth,st->token)){textReply(fd,403,"Forbidden","Connect using the code on the Switch first.");return;}st->lastSeenSeconds.store(static_cast(std::time(nullptr))); +if(method=="GET"&&path=="/api/status"){textReply(fd,200,"OK","{\"connected\":true}");return;} +if(method=="GET"&&path=="/api/config"){std::ifstream in(st->dataDirectory+"/branding.ini",std::ios::binary);std::string app=portalAppName,one=portalNoteSingular,many=portalNotePlural;if(in){std::string v;if(std::getline(in,v)&&!v.empty())app=v;if(std::getline(in,v)&&!v.empty())one=v;if(std::getline(in,v)&&!v.empty())many=v;}textReply(fd,200,"OK","{\"app\":"+jsonEscape(app)+",\"singular\":"+jsonEscape(one)+",\"plural\":"+jsonEscape(many)+"}");return;} +if(method=="GET"&&path=="/api/bundle"){ + branding::Bundle bundle;std::ifstream config(st->dataDirectory+"/branding.ini",std::ios::binary); + if(config){std::string value;if(std::getline(config,value)&&!value.empty())bundle.app=value;else bundle.app=portalAppName;if(std::getline(config,value)&&!value.empty())bundle.singular=value;else bundle.singular=portalNoteSingular;if(std::getline(config,value)&&!value.empty())bundle.plural=value;else bundle.plural=portalNotePlural;} + else{bundle.app=portalAppName;bundle.singular=portalNoteSingular;bundle.plural=portalNotePlural;} + std::string logoPath=st->dataDirectory+"/branding-logo.png";std::ifstream logo(logoPath,std::ios::binary|std::ios::ate); + if(!logo){logoPath=st->assetsRoot+"/art/switchnotedefault.png";logo.open(logoPath,std::ios::binary|std::ios::ate);} + if(!logo){logEvent(st,"branding export failed: no logo available");textReply(fd,500,"Internal Server Error","No title logo is available to export.");return;} + const std::streamoff logoSize=logo.tellg();if(logoSize<=0||logoSize>8*1024*1024){textReply(fd,500,"Internal Server Error","The title logo has an invalid size.");return;} + bundle.logo.resize(static_cast(logoSize));logo.seekg(0);logo.read(reinterpret_cast(bundle.logo.data()),logoSize);if(!logo){textReply(fd,500,"Internal Server Error","Could not read the title logo.");return;}logo.close(); + const char* image=reinterpret_cast(bundle.logo.data());const bool png=bundle.logo.size()>=8&&std::memcmp(image,"\x89PNG\r\n\x1a\n",8)==0;const bool jpg=bundle.logo.size()>=3&&static_cast(image[0])==0xff&&static_cast(image[1])==0xd8&&static_cast(image[2])==0xff;if(!png&&!jpg){textReply(fd,415,"Unsupported Media Type","The current logo is not a supported PNG or JPEG.");return;} + const std::string temp=st->dataDirectory+"/branding-export.pending.config";std::string error;if(!branding::writeConfig(temp,bundle,error)){logEvent(st,"branding export failed: "+error);textReply(fd,500,"Internal Server Error",error);return;} + std::ifstream archive(temp,std::ios::binary|std::ios::ate);const std::streamoff size=archive?static_cast(archive.tellg()):-1;if(size<22||size>9*1024*1024){archive.close();std::remove(temp.c_str());textReply(fd,500,"Internal Server Error","Could not prepare branding.config.");return;}std::string bytes(static_cast(size),'\0');archive.seekg(0);archive.read(bytes.data(),static_cast(size));archive.close();std::remove(temp.c_str());if(!archive){textReply(fd,500,"Internal Server Error","Could not read exported branding.config.");return;}logEvent(st,"branding config exported: "+std::to_string(bytes.size())+" bytes");reply(fd,200,"OK","application/octet-stream",bytes);return; +} +if(method=="POST"&&path=="/api/bundle"){ + if(bodySize<22||bodySize>kMaxRequest){textReply(fd,400,"Bad Request","Branding file size is invalid.");return;} + const std::string temp=st->dataDirectory+"/branding-import.pending.config";{std::ofstream file(temp,std::ios::binary|std::ios::trunc);if(!file){textReply(fd,500,"Internal Server Error","Could not open a temporary branding file.");return;}file.write(req.data()+bodyAt,static_cast(bodySize));file.flush();if(!file){textReply(fd,500,"Internal Server Error","Could not store the branding file.");return;}} + branding::Bundle bundle;std::string error;const bool parsed=branding::readConfig(temp,bundle,error);std::remove(temp.c_str());if(!parsed){logEvent(st,"branding import rejected: "+error);textReply(fd,400,"Bad Request",error);return;} + auto valid=[](const std::string& value){return std::all_of(value.begin(),value.end(),[](unsigned char c){return std::isalnum(c)||c==' '||c=='-'||c=='_'||c=='.'||c>=128;});};if(!cleanValue(bundle.app,40)||!cleanValue(bundle.singular,24)||!cleanValue(bundle.plural,24)||!valid(bundle.app)||!valid(bundle.singular)||!valid(bundle.plural)){textReply(fd,400,"Bad Request","Branding names contain unsupported characters or exceed their limits.");return;} + const char* image=reinterpret_cast(bundle.logo.data());const bool png=bundle.logo.size()>=8&&std::memcmp(image,"\x89PNG\r\n\x1a\n",8)==0;const bool jpg=bundle.logo.size()>=3&&static_cast(image[0])==0xff&&static_cast(image[1])==0xd8&&static_cast(image[2])==0xff;if(!png&&!jpg){textReply(fd,415,"Unsupported Media Type","Branding config must contain a PNG or JPEG logo.");return;} + const std::string logoPath=st->dataDirectory+"/branding-logo.pending";{std::ofstream logo(logoPath,std::ios::binary|std::ios::trunc);if(!logo){textReply(fd,500,"Internal Server Error","Could not stage the title logo.");return;}logo.write(reinterpret_cast(bundle.logo.data()),static_cast(bundle.logo.size()));logo.flush();if(!logo){textReply(fd,500,"Internal Server Error","Could not write the title logo.");return;}} + const std::string configPath=st->dataDirectory+"/branding.ini";{std::ofstream config(configPath,std::ios::binary|std::ios::trunc);if(!config){std::remove(logoPath.c_str());textReply(fd,500,"Internal Server Error","Could not open branding settings on the SD card.");return;}config<pending.store(true,std::memory_order_release);textReply(fd,200,"OK","Imported.");return; +} +if(method=="PUT"&&path=="/api/logo"){if(bodySize<8||bodySize>8*1024*1024){logEvent(st,"logo upload rejected: invalid size");textReply(fd,400,"Bad Request","Logo must be 8 MB or smaller.");return;}const char* b=req.data()+bodyAt;bool png=bodySize>=8&&std::memcmp(b,"\x89PNG\r\n\x1a\n",8)==0;bool jpg=bodySize>=3&&static_cast(b[0])==0xff&&static_cast(b[1])==0xd8&&static_cast(b[2])==0xff;if(!png&&!jpg){logEvent(st,"logo upload rejected: invalid image signature");textReply(fd,415,"Unsupported Media Type","Choose a valid PNG or JPEG logo.");return;}std::string tmp=st->dataDirectory+"/branding-logo.pending";std::ofstream out(tmp,std::ios::binary|std::ios::trunc);if(!out){logEvent(st,"logo upload failed: pending file open failed");textReply(fd,500,"Internal Server Error","Could not open logo file on the SD card.");return;}out.write(b,static_cast(bodySize));out.flush();out.close();if(!out){logEvent(st,"logo upload failed: pending file write failed");textReply(fd,500,"Internal Server Error","Could not store logo on the SD card.");return;}logEvent(st,"logo uploaded: "+std::to_string(bodySize)+" bytes");textReply(fd,200,"OK","Logo received.");return;} +if(method=="PUT"&&path.size()==14&&path.rfind("/api/loading/",0)==0&&path.back()>='0'&&path.back()<='7'){if(bodySize<8||bodySize>128*1024){textReply(fd,400,"Bad Request","Loading frame is too large.");return;}const char*b=req.data()+bodyAt;if(std::memcmp(b,"\x89PNG\r\n\x1a\n",8)!=0){textReply(fd,415,"Unsupported Media Type","Loading frames must be PNG images.");return;}const std::string file=st->dataDirectory+"/branding-loading"+path.substr(path.size()-1)+".pending";std::ofstream out(file,std::ios::binary|std::ios::trunc);if(!out){textReply(fd,500,"Internal Server Error","Could not save loading frame.");return;}out.write(b,static_cast(bodySize));out.flush();if(!out){textReply(fd,500,"Internal Server Error","Could not write loading frame.");return;}logEvent(st,"loading frame uploaded: "+path.substr(path.size()-1));textReply(fd,200,"OK","Frame received.");return;} +if(method=="POST"&&path=="/api/config"){auto v=fields(req.substr(bodyAt,bodySize));std::string app=v["app"],one=v["singular"],many=v["plural"];if(!cleanValue(app,40)||!cleanValue(one,24)||!cleanValue(many,24)){textReply(fd,400,"Bad Request","Enter names within the listed limits.");return;}auto valid=[](const std::string&s){return std::all_of(s.begin(),s.end(),[](unsigned char c){return std::isalnum(c)||c==' '||c=='-'||c=='_'||c=='.'||c>=128;});};if(!valid(app)||!valid(one)||!valid(many)){textReply(fd,400,"Bad Request","Names may use letters, numbers, spaces, hyphens, underscores and periods.");return;} + // libnx's std::rename over FAT/SD can fail when replacing an existing + // file, even after the old destination has been removed. Write the small + // config directly instead of reporting a false save failure. + const std::string dst=st->dataDirectory+"/branding.ini"; + std::ofstream out(dst,std::ios::binary|std::ios::trunc); + if(!out){logEvent(st,"branding save failed: destination open failed");textReply(fd,500,"Internal Server Error","Could not open branding file on the SD card.");return;} + out<pending.store(true,std::memory_order_release);textReply(fd,200,"OK","Saved.");return;} +textReply(fd,404,"Not Found","Not found."); +} +void serverMain(void* arg){State* st=static_cast(arg);while(st->running.load()){fd_set set;FD_ZERO(&set);FD_SET(st->listenFd,&set);timeval wait{0,200000};if(select(st->listenFd+1,&set,nullptr,nullptr,&wait)<=0)continue;sockaddr_in peer{};socklen_t size=sizeof(peer);int fd=accept(st->listenFd,reinterpret_cast(&peer),&size);if(fd<0)continue;st->activeClient.store(fd);timeval timeout{5,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));handle(st,fd);st->activeClient.store(-1);::close(fd);}if(st->listenFd>=0){::close(st->listenFd);st->listenFd=-1;}st->running.store(false);} +#endif +} + +BrandingConfigServer::BrandingConfigServer() = default; +BrandingConfigServer::~BrandingConfigServer(){stop();} +bool BrandingConfigServer::start(const std::string& ip,const std::string& data,const std::string& assets,const std::string& code,std::string& error,const std::string& appName,const std::string& noteSingular,const std::string& notePlural){ +#ifdef __SWITCH__ + portalAppName = appName; + portalNoteSingular = noteSingular; + portalNotePlural = notePlural; + stop();State* s=new(std::nothrow) State();if(!s){error="Not enough memory to start the portal.";return false;}s->bindAddress=ip;s->dataDirectory=data;s->assetsRoot=assets;s->code=code;s->listenFd=::socket(AF_INET,SOCK_STREAM,0);if(s->listenFd<0){error="Could not create the local web server.";delete s;return false;}int yes=1;setsockopt(s->listenFd,SOL_SOCKET,SO_REUSEADDR,&yes,sizeof(yes));sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(kPort);if(inet_pton(AF_INET,ip.c_str(),&a.sin_addr)!=1||bind(s->listenFd,reinterpret_cast(&a),sizeof(a))<0||listen(s->listenFd,4)<0){error="Could not start the portal on port 8767.";::close(s->listenFd);delete s;return false;}s->running.store(true);if(R_FAILED(threadCreate(&s->thread,serverMain,s,nullptr,0x10000,0x3b,-2))){error="Could not create the portal service thread.";s->running.store(false);::close(s->listenFd);delete s;return false;}if(R_FAILED(threadStart(&s->thread))){error="Could not start the portal service.";s->running.store(false);::close(s->listenFd);threadClose(&s->thread);delete s;return false;}impl_=s;return true; +#else + (void)ip;(void)data;(void)assets;(void)code;(void)appName;(void)noteSingular;(void)notePlural;error="The branding portal is available in the Switch homebrew app.";return false; +#endif +} +void BrandingConfigServer::stop(){ +#ifdef __SWITCH__ + State*s=static_cast(impl_);if(!s)return;s->running.store(false);int c=s->activeClient.load();if(c>=0)::shutdown(c,SHUT_RDWR);if(s->listenFd>=0)::shutdown(s->listenFd,SHUT_RDWR);threadWaitForExit(&s->thread);threadClose(&s->thread);delete s;impl_=nullptr; +#endif +} +bool BrandingConfigServer::isRunning()const{ +#ifdef __SWITCH__ + auto*s=static_cast(impl_);return s&&s->running.load(); +#else + return false; +#endif +} +bool BrandingConfigServer::isConnected()const{ +#ifdef __SWITCH__ + auto*s=static_cast(impl_);if(!s||!s->connected.load())return false;const auto now=static_cast(std::time(nullptr));const auto last=s->lastSeenSeconds.load();return now>=last&&now-last<=6; +#else + return false; +#endif +} +bool BrandingConfigServer::takeConfigUpdate(){ +#ifdef __SWITCH__ + auto*s=static_cast(impl_);return s&&s->pending.exchange(false,std::memory_order_acq_rel); +#else + return false; +#endif +} diff --git a/source/export.cpp b/source/export.cpp new file mode 100644 index 0000000..1efbc68 --- /dev/null +++ b/source/export.cpp @@ -0,0 +1,596 @@ +#include "export.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sppm { +namespace { + +using Bytes = std::vector; + +void be32(Bytes& out, std::uint32_t value) { + out.push_back(static_cast(value >> 24)); + out.push_back(static_cast(value >> 16)); + out.push_back(static_cast(value >> 8)); + out.push_back(static_cast(value)); +} +void be16(Bytes& out, std::uint16_t value) { + out.push_back(static_cast(value >> 8)); + out.push_back(static_cast(value)); +} +void type(Bytes& out, const char* value) { + out.insert(out.end(), value, value + 4); +} +std::size_t boxBegin(Bytes& out, const char* name) { + const std::size_t start = out.size(); + be32(out, 0); type(out, name); + return start; +} +void boxEnd(Bytes& out, std::size_t start) { + const std::size_t size = out.size() - start; + out[start] = static_cast(size >> 24); + out[start + 1] = static_cast(size >> 16); + out[start + 2] = static_cast(size >> 8); + out[start + 3] = static_cast(size); +} +std::size_t fullBoxBegin(Bytes& out, const char* name, std::uint8_t version = 0, + std::uint32_t flags = 0) { + const auto start = boxBegin(out, name); + out.push_back(version); + out.push_back(static_cast(flags >> 16)); + out.push_back(static_cast(flags >> 8)); + out.push_back(static_cast(flags)); + return start; +} + +Bytes mp4MovieBox(const std::vector& sizes, + const std::vector& offsets, + double fps) { + constexpr std::uint32_t timeScale = 1000000; + const std::uint32_t frameDelta = static_cast(std::lround(timeScale / fps)); + const std::uint32_t duration = static_cast(std::llround(sizes.size() * static_cast(timeScale) / fps)); + Bytes out; + auto moov = boxBegin(out, "moov"); + auto mvhd = fullBoxBegin(out, "mvhd"); + be32(out, 0); be32(out, 0); be32(out, timeScale); + be32(out, duration); + be32(out, 0x00010000); be16(out, 0x0100); be16(out, 0); + be32(out, 0); be32(out, 0); + const std::uint32_t matrix[] = {0x00010000,0,0, 0,0x00010000,0, 0,0,0x40000000}; + for (auto value : matrix) be32(out, value); + for (int i = 0; i < 6; ++i) be32(out, 0); + be32(out, 2); boxEnd(out, mvhd); + + auto trak = boxBegin(out, "trak"); + auto tkhd = fullBoxBegin(out, "tkhd", 0, 7); + be32(out, 0); be32(out, 0); be32(out, 1); be32(out, 0); + be32(out, duration); + be32(out, 0); be32(out, 0); be16(out, 0); be16(out, 0); be16(out, 0); be16(out, 0); + for (auto value : matrix) be32(out, value); + be32(out, static_cast(kWidth) << 16); + be32(out, static_cast(kHeight) << 16); boxEnd(out, tkhd); + + auto mdia = boxBegin(out, "mdia"); + auto mdhd = fullBoxBegin(out, "mdhd"); + be32(out, 0); be32(out, 0); be32(out, timeScale); + be32(out, duration); be16(out, 0x55c4); be16(out, 0); + boxEnd(out, mdhd); + auto hdlr = fullBoxBegin(out, "hdlr"); + be32(out, 0); type(out, "vide"); be32(out, 0); be32(out, 0); be32(out, 0); + out.insert(out.end(), {'S','w','i','t','c','h','n','o','t','e',' ','V','i','d','e','o',0}); + boxEnd(out, hdlr); + auto minf = boxBegin(out, "minf"); + auto vmhd = fullBoxBegin(out, "vmhd", 0, 1); + be16(out, 0); be16(out, 0); be16(out, 0); be16(out, 0); boxEnd(out, vmhd); + auto dinf = boxBegin(out, "dinf"); + auto dref = fullBoxBegin(out, "dref"); be32(out, 1); + auto url = fullBoxBegin(out, "url ", 0, 1); boxEnd(out, url); boxEnd(out, dref); boxEnd(out, dinf); + auto stbl = boxBegin(out, "stbl"); + auto stsd = fullBoxBegin(out, "stsd"); be32(out, 1); + auto jpeg = boxBegin(out, "mjpg"); + be32(out, 0); be16(out, 0); be16(out, 1); + be16(out, 0); be16(out, 0); be32(out, 0); be32(out, 0); be32(out, 0); + be16(out, kWidth); be16(out, kHeight); be32(out, 0x00480000); be32(out, 0x00480000); + be32(out, 0); be16(out, 1); out.insert(out.end(), 32, 0); be16(out, 0x0018); be16(out, 0xffff); + boxEnd(out, jpeg); boxEnd(out, stsd); + auto stts = fullBoxBegin(out, "stts"); be32(out, 1); be32(out, static_cast(sizes.size())); be32(out, frameDelta); boxEnd(out, stts); + auto stsc = fullBoxBegin(out, "stsc"); be32(out, 1); be32(out, 1); be32(out, 1); be32(out, 1); boxEnd(out, stsc); + auto stsz = fullBoxBegin(out, "stsz"); be32(out, 0); be32(out, static_cast(sizes.size())); + for (auto size : sizes) be32(out, size); + boxEnd(out, stsz); + auto stco = fullBoxBegin(out, "stco"); be32(out, static_cast(offsets.size())); + for (auto offset : offsets) be32(out, offset); + boxEnd(out, stco); + auto stss = fullBoxBegin(out, "stss"); be32(out, static_cast(sizes.size())); + for (std::size_t i = 0; i < sizes.size(); ++i) be32(out, static_cast(i + 1)); + boxEnd(out, stss); + boxEnd(out, stbl); boxEnd(out, minf); boxEnd(out, mdia); boxEnd(out, trak); boxEnd(out, moov); + return out; +} + +Color opaqueFramePixel(const Frame& frame, const Palette& palette, std::size_t pixel) { + Color result{255, 255, 255, 255}; + for (std::size_t layerIndex = frame.layers.size(); layerIndex-- > 0;) { + const auto index = frame.layers[layerIndex][pixel]; + if (!index) continue; + const auto& source = palette[index]; + if (!source.a) continue; + const unsigned alpha = source.a; + result.r = static_cast((source.r * alpha + result.r * (255 - alpha) + 127) / 255); + result.g = static_cast((source.g * alpha + result.g * (255 - alpha) + 127) / 255); + result.b = static_cast((source.b * alpha + result.b * (255 - alpha) + 127) / 255); + } + return result; +} + +bool checkDocument(const Document& document, bool cover, std::string& error) { + if (document.frames.empty() || document.frames.size() > kMaxFrames) { + error = "frame count must be between 1 and 9999"; + return false; + } + if (document.metadata.fps < 1 || document.metadata.fps > 30) { + error = "frame rate must be between 1 and 30 fps"; + return false; + } + if (cover && document.metadata.coverFrame >= document.frames.size()) { + error = "cover frame is out of range"; + return false; + } + for (const Frame& frame : document.frames) { + for (const auto& layer : frame.layers) { + if (layer.size() != kPixelsPerLayer) { + error = "a layer has the wrong pixel count"; + return false; + } + } + } + error.clear(); + return true; +} + +// Straight-alpha source-over compositing, with A foremost and D rearmost. +Color compositePixel(const Frame& frame, const Palette& palette, + std::size_t pixel) { + Color result{0, 0, 0, 0}; + for (std::size_t layerIndex = frame.layers.size(); layerIndex-- > 0;) { + const auto& layer = frame.layers[layerIndex]; + const std::uint8_t index = layer[pixel]; + if (index == 0) continue; + const Color& source = palette[index]; + if (source.a == 0) continue; + if (source.a == 255) { + result = source; + continue; + } + const std::uint32_t sourceWeight = + static_cast(source.a) * 255U; + const std::uint32_t behindWeight = + static_cast(result.a) * (255U - source.a); + const std::uint32_t total = sourceWeight + behindWeight; + result.r = static_cast( + (source.r * sourceWeight + result.r * behindWeight + total / 2U) / total); + result.g = static_cast( + (source.g * sourceWeight + result.g * behindWeight + total / 2U) / total); + result.b = static_cast( + (source.b * sourceWeight + result.b * behindWeight + total / 2U) / total); + result.a = static_cast((total + 127U) / 255U); + } + return result; +} + +std::uint8_t nearestPaletteIndex(const Color& color, const Palette& palette) { + std::uint32_t bestDistance = 3U * 255U * 255U + 1U; + std::uint8_t best = 0; + for (std::size_t i = 1; i < palette.size(); ++i) { + const Color& candidate = palette[i]; + if (candidate.a == 0) continue; + const int dr = static_cast(color.r) - candidate.r; + const int dg = static_cast(color.g) - candidate.g; + const int db = static_cast(color.b) - candidate.b; + const std::uint32_t distance = + static_cast(dr * dr + dg * dg + db * db); + if (distance < bestDistance) { + bestDistance = distance; + best = static_cast(i); + if (distance == 0) break; + } + } + return best; +} + +// GIF has a single transparent index, so partially transparent colors are +// composited first, then matched to the nearest RGB color in the palette. +std::vector gifPixels(const Frame& frame, + const Palette& palette) { + std::vector pixels(kPixelsPerLayer, 0); + bool hasPartialAlpha = false; + for (std::size_t i = 1; i < palette.size(); ++i) { + if (palette[i].a != 0 && palette[i].a != 255) { + hasPartialAlpha = true; + break; + } + } + if (!hasPartialAlpha) { + for (std::size_t layerIndex = frame.layers.size(); layerIndex-- > 0;) { + const auto& layer = frame.layers[layerIndex]; + for (std::size_t pixel = 0; pixel < pixels.size(); ++pixel) { + const std::uint8_t index = layer[pixel]; + if (index != 0 && palette[index].a != 0) pixels[pixel] = index; + } + } + return pixels; + } + + std::unordered_map matchCache; + matchCache.reserve(512); + for (std::size_t pixel = 0; pixel < pixels.size(); ++pixel) { + const Color color = compositePixel(frame, palette, pixel); + if (color.a < 128) continue; + const std::uint32_t key = + (static_cast(color.r) << 16U) | + (static_cast(color.g) << 8U) | color.b; + const auto found = matchCache.find(key); + if (found != matchCache.end()) { + pixels[pixel] = found->second; + } else { + const std::uint8_t index = nearestPaletteIndex(color, palette); + matchCache.emplace(key, index); + pixels[pixel] = index; + } + } + return pixels; +} + +struct BitPacker { + std::vector data; + std::uint32_t pending = 0; + unsigned pendingBits = 0; + + void code(std::uint16_t value, unsigned width) { + pending |= static_cast(value) << pendingBits; + pendingBits += width; + while (pendingBits >= 8) { + data.push_back(static_cast(pending & 0xFFU)); + pending >>= 8U; + pendingBits -= 8U; + } + } + + void finish() { + if (pendingBits != 0) data.push_back(static_cast(pending)); + } +}; + +std::vector gifLzw(const std::vector& pixels) { + constexpr std::uint16_t clearCode = 256; + constexpr std::uint16_t endCode = 257; + std::unordered_map dictionary; + dictionary.reserve(4096); + std::uint16_t nextCode = 258; + unsigned codeWidth = 9; + BitPacker writer; + writer.data.reserve(pixels.size() / 2); + writer.code(clearCode, codeWidth); + std::uint16_t prefix = pixels.front(); + + for (std::size_t i = 1; i < pixels.size(); ++i) { + const std::uint8_t suffix = pixels[i]; + const std::uint32_t key = + (static_cast(prefix) << 8U) | suffix; + const auto found = dictionary.find(key); + if (found != dictionary.end()) { + prefix = found->second; + continue; + } + + writer.code(prefix, codeWidth); + if (nextCode < 4096) { + dictionary.emplace(key, nextCode++); + // GIF's decoder learns each new phrase one code later. + if (codeWidth < 12 && nextCode > (1U << codeWidth)) ++codeWidth; + } else { + writer.code(clearCode, codeWidth); + dictionary.clear(); + nextCode = 258; + codeWidth = 9; + } + prefix = suffix; + } + writer.code(prefix, codeWidth); + writer.code(endCode, codeWidth); + writer.finish(); + return writer.data; +} + +void byte(std::ostream& out, std::uint8_t value) { + out.put(static_cast(value)); +} + +void le16(std::ostream& out, std::uint16_t value) { + byte(out, static_cast(value)); + byte(out, static_cast(value >> 8U)); +} + +void be32(std::ostream& out, std::uint32_t value) { + byte(out, static_cast(value >> 24U)); + byte(out, static_cast(value >> 16U)); + byte(out, static_cast(value >> 8U)); + byte(out, static_cast(value)); +} + +std::uint32_t crc32(const char* type, const std::vector& data) { + std::uint32_t crc = 0xFFFFFFFFU; + auto update = [&crc](std::uint8_t value) { + crc ^= value; + for (unsigned bit = 0; bit < 8; ++bit) { + crc = (crc >> 1U) ^ ((crc & 1U) ? 0xEDB88320U : 0U); + } + }; + for (int i = 0; i < 4; ++i) update(static_cast(type[i])); + for (std::uint8_t value : data) update(value); + return crc ^ 0xFFFFFFFFU; +} + +void pngChunk(std::ostream& out, const char* type, + const std::vector& data) { + be32(out, static_cast(data.size())); + out.write(type, 4); + if (!data.empty()) { + out.write(reinterpret_cast(data.data()), + static_cast(data.size())); + } + be32(out, crc32(type, data)); +} + +std::vector zlibStored(const std::vector& raw) { + std::vector encoded; + encoded.reserve(raw.size() + 6 + (raw.size() / 65535 + 1) * 5); + encoded.push_back(0x78); // zlib CMF: deflate, 32 KiB window + encoded.push_back(0x01); // zlib FLG: no compression + std::size_t offset = 0; + while (offset < raw.size()) { + const std::size_t length = std::min(65535, raw.size() - offset); + const bool final = offset + length == raw.size(); + const std::uint16_t len = static_cast(length); + const std::uint16_t inverse = static_cast(~len); + encoded.push_back(final ? 0x01 : 0x00); + encoded.push_back(static_cast(len)); + encoded.push_back(static_cast(len >> 8U)); + encoded.push_back(static_cast(inverse)); + encoded.push_back(static_cast(inverse >> 8U)); + encoded.insert(encoded.end(), raw.begin() + static_cast(offset), + raw.begin() + static_cast(offset + length)); + offset += length; + } + std::uint32_t a = 1; + std::uint32_t b = 0; + for (std::uint8_t value : raw) { + a = (a + value) % 65521U; + b = (b + a) % 65521U; + } + const std::uint32_t adler = (b << 16U) | a; + encoded.push_back(static_cast(adler >> 24U)); + encoded.push_back(static_cast(adler >> 16U)); + encoded.push_back(static_cast(adler >> 8U)); + encoded.push_back(static_cast(adler)); + return encoded; +} + +} // namespace + +bool exportGif(const Document& document, const std::string& path, + std::string& error) { + if (!checkDocument(document, false, error)) return false; + if (path.empty()) { + error = "GIF output path is empty"; + return false; + } + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) { + error = "could not open GIF output"; + return false; + } + out.write("GIF89a", 6); + le16(out, kWidth); + le16(out, kHeight); + byte(out, 0xF7); // global 256-color table, 8-bit color resolution + byte(out, 1); // white in the default palette as the canvas background + byte(out, 0); + for (const Color& color : document.palette) { + byte(out, color.r); + byte(out, color.g); + byte(out, color.b); + } + + // Netscape application extension: loop indefinitely. + const std::array loop = { + 0x21, 0xFF, 0x0B, 'N', 'E', 'T', 'S', 'C', 'A', 'P', 'E', '2', '.', '0', + 0x03, 0x01, 0x00, 0x00, 0x00}; + out.write(reinterpret_cast(loop.data()), + static_cast(loop.size())); + + for (std::size_t i = 0; i < document.frames.size(); ++i) { + const double fps = frameRate(document.metadata); + const unsigned start = static_cast(std::llround(i * 100.0 / fps)); + const unsigned end = static_cast(std::llround((i + 1U) * 100.0 / fps)); + const std::uint16_t delay = + static_cast(std::max(1U, end - start)); + byte(out, 0x21); byte(out, 0xF9); byte(out, 0x04); + byte(out, 0x09); // restore background before next frame, index 0 transparent + le16(out, delay); + byte(out, 0); byte(out, 0); + + byte(out, 0x2C); // full-canvas image descriptor + le16(out, 0); le16(out, 0); + le16(out, kWidth); le16(out, kHeight); + byte(out, 0); // use global palette + byte(out, 8); // LZW minimum code size + const std::vector pixels = + gifPixels(document.frames[i], document.palette); + const std::vector compressed = gifLzw(pixels); + for (std::size_t offset = 0; offset < compressed.size();) { + const std::size_t length = + std::min(255, compressed.size() - offset); + byte(out, static_cast(length)); + out.write(reinterpret_cast(compressed.data() + offset), + static_cast(length)); + offset += length; + } + byte(out, 0); // terminate LZW sub-blocks + if (!out) { + error = "could not write GIF frame"; + return false; + } + } + byte(out, 0x3B); + out.flush(); + if (!out) { + error = "could not finish GIF output"; + return false; + } + error.clear(); + return true; +} + +bool exportPngCover(const Document& document, const std::string& path, + std::string& error) { + if (!checkDocument(document, true, error)) return false; + if (path.empty()) { + error = "PNG output path is empty"; + return false; + } + const Frame& frame = document.frames[document.metadata.coverFrame]; + std::vector raw; + raw.reserve(static_cast(kHeight) * (1U + 4U * kWidth)); + for (std::size_t y = 0; y < kHeight; ++y) { + raw.push_back(0); // PNG filter type 0 + for (std::size_t x = 0; x < kWidth; ++x) { + const Color pixel = compositePixel(frame, document.palette, + y * kWidth + x); + raw.push_back(pixel.r); + raw.push_back(pixel.g); + raw.push_back(pixel.b); + raw.push_back(pixel.a); + } + } + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) { + error = "could not open PNG output"; + return false; + } + constexpr std::array signature = + {137, 80, 78, 71, 13, 10, 26, 10}; + out.write(reinterpret_cast(signature.data()), + static_cast(signature.size())); + const std::vector header = { + static_cast(kWidth >> 24U), + static_cast(kWidth >> 16U), + static_cast(kWidth >> 8U), + static_cast(kWidth), + static_cast(kHeight >> 24U), + static_cast(kHeight >> 16U), + static_cast(kHeight >> 8U), + static_cast(kHeight), + 8, 6, 0, 0, 0}; // RGBA, 8 bits/channel, no interlace + pngChunk(out, "IHDR", header); + pngChunk(out, "IDAT", zlibStored(raw)); + pngChunk(out, "IEND", {}); + out.flush(); + if (!out) { + error = "could not finish PNG output"; + return false; + } + error.clear(); + return true; +} + +bool exportMp4(const Document& document, const std::string& path, + std::string& error) { + if (!checkDocument(document, false, error)) return false; + if (path.empty()) { error = "MP4 output path is empty"; return false; } + const double fps = frameRate(document.metadata); + if (!(fps > 0.0 && fps <= 30.0)) { error = "frame rate must be between 0 and 30 fps"; return false; } + + Bytes ftyp; + auto ftypBox = boxBegin(ftyp, "ftyp"); + type(ftyp, "isom"); be32(ftyp, 0x00000200); type(ftyp, "isom"); type(ftyp, "iso2"); type(ftyp, "mp41"); type(ftyp, "mjpg"); + boxEnd(ftyp, ftypBox); + + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) { error = "could not create MP4 file"; return false; } + out.write(reinterpret_cast(ftyp.data()), static_cast(ftyp.size())); + const std::uint32_t mdatStart = static_cast(ftyp.size()); + const std::uint8_t mdatHeader[8] = {0,0,0,0,'m','d','a','t'}; + out.write(reinterpret_cast(mdatHeader), sizeof(mdatHeader)); + std::vector sampleSizes; + std::vector sampleOffsets; + sampleSizes.reserve(document.frames.size()); + sampleOffsets.reserve(document.frames.size()); + + constexpr int jpegCapacity = 2 * 1024 * 1024; + std::vector jpegBuffer(static_cast(jpegCapacity)); + for (std::size_t frameIndex = 0; frameIndex < document.frames.size(); ++frameIndex) { + SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, kWidth, kHeight, 32, SDL_PIXELFORMAT_RGBA32); + if (!surface) { error = "could not allocate frame image"; return false; } + if (SDL_LockSurface(surface) != 0) { + SDL_FreeSurface(surface); error = "could not prepare frame image"; return false; + } + auto* rows = static_cast(surface->pixels); + for (std::size_t y = 0; y < kHeight; ++y) { + auto* row = reinterpret_cast(rows + y * static_cast(surface->pitch)); + for (std::size_t x = 0; x < kWidth; ++x) { + const Color pixel = opaqueFramePixel(document.frames[frameIndex], document.palette, y * kWidth + x); + row[x] = SDL_MapRGBA(surface->format, pixel.r, pixel.g, pixel.b, 255); + } + } + SDL_UnlockSurface(surface); + SDL_RWops* jpegStream = SDL_RWFromMem(jpegBuffer.data(), jpegCapacity); + if (!jpegStream || IMG_SaveJPG_RW(surface, jpegStream, 0, 82) != 0) { + if (jpegStream) SDL_RWclose(jpegStream); + SDL_FreeSurface(surface); error = "could not encode an MP4 frame as JPEG"; return false; + } + const Sint64 jpegSize = SDL_RWtell(jpegStream); + SDL_RWclose(jpegStream); + SDL_FreeSurface(surface); + if (jpegSize <= 0 || jpegSize > jpegCapacity) { error = "encoded frame is too large"; return false; } + const auto position = out.tellp(); + if (position < 0 || static_cast(position) > UINT32_MAX) { + error = "MP4 exceeds the supported 4 GB file limit"; return false; + } + sampleOffsets.push_back(static_cast(position)); + sampleSizes.push_back(static_cast(jpegSize)); + out.write(reinterpret_cast(jpegBuffer.data()), jpegSize); + if (!out) { error = "could not write MP4 frame data"; return false; } + } + const auto endOfMedia = out.tellp(); + if (endOfMedia < 0 || static_cast(endOfMedia) - mdatStart > UINT32_MAX) { + error = "MP4 exceeds the supported 4 GB file limit"; return false; + } + const std::uint32_t mdatSize = static_cast(static_cast(endOfMedia) - mdatStart); + out.seekp(mdatStart); + const std::uint8_t encodedMdatSize[4] = { + static_cast(mdatSize >> 24), static_cast(mdatSize >> 16), + static_cast(mdatSize >> 8), static_cast(mdatSize)}; + out.write(reinterpret_cast(encodedMdatSize), sizeof(encodedMdatSize)); + out.seekp(endOfMedia); + const Bytes moov = mp4MovieBox(sampleSizes, sampleOffsets, fps); + out.write(reinterpret_cast(moov.data()), static_cast(moov.size())); + out.flush(); + if (!out) { error = "could not finish MP4 index"; return false; } + out.close(); + error.clear(); + return true; +} + +} // namespace sppm diff --git a/source/main.cpp b/source/main.cpp new file mode 100644 index 0000000..014cb05 --- /dev/null +++ b/source/main.cpp @@ -0,0 +1,7796 @@ +#include +#include +#include +#include + +#ifdef __SWITCH__ +#include +#endif + +#include "sppm.hpp" +#include "export.hpp" +#include "audio_upload_server.hpp" +#include "branding_config_server.hpp" +#include "branding_bundle.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef __SWITCH__ +#include +#include +#include +#include +#include +#include +#endif +#ifdef _WIN32 +#include +#endif +#include +#include +#include + +namespace { + +constexpr int W = 1280; +constexpr int H = 720; +const SDL_Color ORANGE{255, 117, 30, 255}; +const SDL_Color UPDATE_ORANGE{255, 100, 0, 255}; +const SDL_Color CREAM{255, 211, 166, 255}; +const SDL_Color GREEN{86, 166, 41, 255}; +const SDL_Color PALE_GREEN{169, 235, 129, 255}; +const SDL_Color INK{28, 30, 35, 255}; +const SDL_Color DRAW_BLACK{28, 30, 35, 255}; +const SDL_Color LIGHT{226, 230, 242, 255}; +const SDL_Color WHITE{255, 255, 255, 255}; +const SDL_Color GRAY{140, 145, 150, 255}; + +constexpr std::uint32_t kScreenFadeMs = 250; +constexpr float kCanvasChromeMs = 125.0f; +constexpr int kCanvasChromeTravel = 150; +constexpr std::array kInkPresetIndexes{{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}}; +constexpr const char* kGalleryServerHost = "reximemo.net"; +constexpr const char* kGalleryServerBasePath = "/nswitch"; +constexpr std::uint16_t kGalleryServerPort = 443; +constexpr const char* kInstalledAppVersion = "0.1.51"; +std::atomic galleryRequestCounter{0}; + +std::string jsonStringField(const std::string& json, const std::string& key) { + const std::string marker = "\"" + key + "\""; + std::size_t position = json.find(marker); + if (position == std::string::npos) return {}; + position = json.find(':', position + marker.size()); + if (position == std::string::npos) return {}; + ++position; + while (position < json.size() && std::isspace(static_cast(json[position]))) ++position; + if (position >= json.size() || json[position++] != '"') return {}; + std::string value; + while (position < json.size()) { + const char c = json[position++]; + if (c == '"') return value; + if (c == '\\') { + if (position >= json.size()) return {}; + const char escaped = json[position++]; + switch (escaped) { + case '"': case '\\': case '/': value.push_back(escaped); break; + case 'n': value.push_back('\n'); break; + case 'r': value.push_back('\r'); break; + case 't': value.push_back('\t'); break; + default: return {}; + } + } else value.push_back(c); + } + return {}; +} + +bool versionParts(const std::string& input, std::vector& parts) { + std::string version = input; + if (version.rfind("Ver ", 0) == 0) version.erase(0, 4); + else if (!version.empty() && (version[0] == 'v' || version[0] == 'V')) version.erase(0, 1); + if (version.empty()) return false; + std::size_t begin = 0; + while (begin < version.size()) { + const std::size_t end = version.find('.', begin); + const std::size_t stop = end == std::string::npos ? version.size() : end; + if (stop == begin) return false; + unsigned long value = 0; + for (std::size_t i = begin; i < stop; ++i) { + if (version[i] < '0' || version[i] > '9') return false; + const unsigned digit = static_cast(version[i] - '0'); + if (value > (std::numeric_limits::max() - digit) / 10) return false; + value = value * 10 + digit; + } + parts.push_back(value); + if (end == std::string::npos) break; + begin = end + 1; + } + return !parts.empty() && version.back() != '.'; +} + +int compareVersions(const std::string& left, const std::string& right) { + std::vector a, b; + if (!versionParts(left, a) || !versionParts(right, b)) return 0; + const std::size_t count = std::max(a.size(), b.size()); + for (std::size_t i = 0; i < count; ++i) { + const unsigned long av = i < a.size() ? a[i] : 0; + const unsigned long bv = i < b.size() ? b[i] : 0; + if (av != bv) return av < bv ? -1 : 1; + } + return 0; +} + +bool trustedUpdatePath(const std::string& url, std::string& path) { + constexpr const char* scheme = "https://"; + if (url.rfind(scheme, 0) != 0) return false; + const std::size_t authorityStart = std::strlen(scheme); + const std::size_t slash = url.find('/', authorityStart); + if (slash == std::string::npos) return false; + const std::string authority = url.substr(authorityStart, slash - authorityStart); + if (authority != kGalleryServerHost && authority != std::string(kGalleryServerHost) + ":443") return false; + const std::string fullPath = url.substr(slash); + if (fullPath.rfind(std::string(kGalleryServerBasePath) + "/", 0) != 0) return false; + path = fullPath.substr(std::strlen(kGalleryServerBasePath)); + return (path == "/api/download" || path.rfind("/api/download?", 0) == 0) && + path.find('#') == std::string::npos; +} + +bool validSha256Hex(const std::string& value) { + return value.size() == 64 && std::all_of(value.begin(), value.end(), [](unsigned char c) { + return std::isxdigit(c) != 0; + }); +} + +bool validNroFile(const std::string& path) { + std::ifstream input(path, std::ios::binary); + if (!input) return false; + input.seekg(0, std::ios::end); + const std::streamoff size = input.tellg(); + if (size < 20 || size > 72LL * 1024LL * 1024LL) return false; + char magic[4]{}; + input.seekg(16, std::ios::beg); + input.read(magic, sizeof(magic)); + return input.gcount() == static_cast(sizeof(magic)) && + std::memcmp(magic, "NRO0", sizeof(magic)) == 0; +} + +std::string canonicalSdPath(std::string path) { + while (!path.empty() && std::isspace(static_cast(path.front()))) path.erase(path.begin()); + while (!path.empty() && std::isspace(static_cast(path.back()))) path.pop_back(); + if (path.size() >= 2 && path.front() == '"' && path.back() == '"') + path = path.substr(1, path.size() - 2); + std::replace(path.begin(), path.end(), '\\', '/'); + std::transform(path.begin(), path.end(), path.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + + if (path.rfind("sdmc:", 0) == 0) { + path.erase(0, 5); + while (!path.empty() && path.front() == '/') path.erase(path.begin()); + return "sdmc:/" + path; + } + while (!path.empty() && path.front() == '/') path.erase(path.begin()); + if (path.rfind("switch/", 0) == 0) return "sdmc:/" + path; + return path; +} + +bool sameSdPath(const std::string& left, const std::string& right) { + return canonicalSdPath(left) == canonicalSdPath(right); +} + +int retryAfterSeconds(const std::string& headers) { + std::istringstream lines(headers); + std::string line; + while (std::getline(lines, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + const std::size_t colon = line.find(':'); + if (colon == std::string::npos) continue; + std::string name = line.substr(0, colon); + std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (name != "retry-after") continue; + std::string value = line.substr(colon + 1); + while (!value.empty() && std::isspace(static_cast(value.front()))) value.erase(value.begin()); + if (value.empty()) return -1; + int seconds = 0; + for (const char c : value) { + if (c < '0' || c > '9') return -1; + if (seconds > 18000) return -1; + seconds = seconds * 10 + (c - '0'); + } + return std::clamp(seconds, 0, 1800); + } + return -1; +} + +enum class Screen { Title, Library, Details, Sequence, Canvas, Viewer, Settings, Gallery, GalleryDetails, GalleryProfile, GalleryComments, CommentCanvas, AppletWarning }; +enum class GalleryLoadKind { Feed, Profile }; +enum class UpdateTask { None, Check, Download }; +enum class Tool { Pen, Brush, Eraser }; +enum class Modal { None, Folder, Audio, Branding, Export, ConfirmDelete, ConfirmGalleryDelete, GalleryPostChoice, GalleryNotice, GalleryOffline, GalleryTerms, Credits, Help, TextInput, Options, PhotoProbe, PhotoChoice, PhotoAlbum, PhotoUpload, Photo, UpdateAvailable }; +enum class ServerStartTask { None, Audio, Photo, Branding }; +enum class Field { None, Title, Description, FolderName, CommentText, GalleryBio }; + +struct ScreenState { + enum class Phase { Idle, ToWhite, FromWhite }; + Screen active = Screen::Title; + Screen target = Screen::Title; + Phase phase = Phase::Idle; + std::uint32_t phaseStart = 0; + + operator Screen() const { return active; } + ScreenState& operator=(Screen next) { + if (phase == Phase::Idle && active == next) return *this; + if (phase != Phase::Idle && target == next) return *this; + target = next; + phase = Phase::ToWhite; + phaseStart = SDL_GetTicks(); + return *this; + } + void beginTitleIntro() { + active = target = Screen::Title; + phase = Phase::FromWhite; + phaseStart = SDL_GetTicks(); + } + bool transitioning() const { return phase != Phase::Idle; } + void advance(std::uint32_t now) { + if (!transitioning() || now - phaseStart < kScreenFadeMs) return; + if (phase == Phase::ToWhite) { + active = target; + phase = Phase::FromWhite; + phaseStart = now; + } else { + phase = Phase::Idle; + } + } + Uint8 whiteAlpha(std::uint32_t now) const { + if (phase == Phase::Idle) return 0; + const float progress = std::clamp((now - phaseStart) / static_cast(kScreenFadeMs), 0.0f, 1.0f); + return static_cast(std::lround((phase == Phase::ToWhite ? progress : 1.0f - progress) * 255.0f)); + } +}; + +struct Item { + std::string title; + std::string path; + std::string artwork; + std::string author; + bool sample = false; + std::string description; + std::string originalAuthor; + std::string authorFsid; + std::string originalAuthorFsid; + std::string currentId; + std::string rootId; + std::uint64_t createdUnixSeconds = 0; + std::uint64_t modifiedUnixSeconds = 0; + bool allowSpinoffs = true; + bool locked = false; + std::string galleryId; + std::string fsid; + std::string ownerIdentity; + int coins = 0; + int views = 0; + std::vector previewBytes; +}; + +struct GalleryComment { + std::string author; + std::string text; + std::string created; + std::vector drawing; +}; + +struct GalleryProfileData { + std::string identity; + std::string username; + std::string bio; + int switchnotes = 0; + int views = 0; + int coins = 0; + int followers = 0; + int following = 0; + bool followed = false; + bool blocked = false; + std::vector avatar; + std::vector uploads; +}; + +struct UndoState { + std::size_t frame = 0; + int layer = 0; + std::vector pixels; +}; + +struct FrameTexture { + SDL_Texture* texture = nullptr; + std::uint64_t revision = 0; +}; + +const std::array kSpeedFps{{1, 1, 2, 4, 6, 12, 20, 30}}; +const std::array kLegacySpeedFps{{3, 5, 8, 12, 15, 18, 24, 30}}; +const std::array kSpeedRates{{0.4985, 1.0, 2.0, 4.0, 5.9972, 11.986, 19.9459, 29.9275}}; + +double selectedFrameRate(int speed, int legacyFps) { + return speed >= 1 && speed <= 8 ? kSpeedRates[static_cast(speed - 1)] : std::max(1, legacyFps); +} + +int speedFromFps(int fps) { + int closest = 0; + for (int i = 1; i < static_cast(kLegacySpeedFps.size()); ++i) { + if (std::abs(kLegacySpeedFps[i] - fps) < std::abs(kLegacySpeedFps[closest] - fps)) closest = i; + } + return closest + 1; +} + +int documentSpeed(const sppm::Metadata& metadata) { + return metadata.speed >= 1 && metadata.speed <= 8 ? metadata.speed : speedFromFps(metadata.fps); +} + +std::string join(const std::string& a, const std::string& b) { + return a + "/" + b; +} + +std::string percentEncode(const std::string& input) { + constexpr char hex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(input.size() * 3); + for (unsigned char ch : input) { + if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || + (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~') { + out.push_back(static_cast(ch)); + } else { + out.push_back('%'); + out.push_back(hex[ch >> 4]); + out.push_back(hex[ch & 15]); + } + } + return out; +} + +int hexDigit(char ch) { + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; + if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; + return -1; +} + +std::string hexEncode(const std::uint8_t* bytes, std::size_t size) { + constexpr char hex[] = "0123456789ABCDEF"; + std::string encoded; + encoded.reserve(size * 2); + for (std::size_t i = 0; i < size; ++i) { + encoded.push_back(hex[bytes[i] >> 4]); + encoded.push_back(hex[bytes[i] & 15]); + } + return encoded; +} + +bool hexDecode(const std::string& encoded, std::uint8_t* bytes, std::size_t size) { + if (encoded.size() != size * 2 || (size && !bytes)) return false; + for (std::size_t i = 0; i < size; ++i) { + const int high = hexDigit(encoded[i * 2]); + const int low = hexDigit(encoded[i * 2 + 1]); + if (high < 0 || low < 0) return false; + bytes[i] = static_cast((high << 4) | low); + } + return true; +} + +std::string percentDecode(const std::string& input) { + std::string out; + out.reserve(input.size()); + for (std::size_t i = 0; i < input.size(); ++i) { + if (input[i] == '%' && i + 2 < input.size()) { + const int hi = hexDigit(input[i + 1]), lo = hexDigit(input[i + 2]); + if (hi >= 0 && lo >= 0) { out.push_back(static_cast((hi << 4) | lo)); i += 2; continue; } + } + if (input[i] == '+') out.push_back(' '); + else out.push_back(input[i]); + } + return out; +} + +std::vector splitTabs(const std::string& line) { + std::vector fields; + std::size_t start = 0; + while (true) { + const std::size_t end = line.find('\t', start); + fields.emplace_back(line.substr(start, end == std::string::npos ? std::string::npos : end - start)); + if (end == std::string::npos) break; + start = end + 1; + } + return fields; +} + +std::string base64Encode(const std::vector& input) { + constexpr char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + out.reserve(((input.size() + 2) / 3) * 4); + for (std::size_t i = 0; i < input.size(); i += 3) { + const std::uint32_t a = input[i]; + const std::uint32_t b = i + 1 < input.size() ? input[i + 1] : 0; + const std::uint32_t c = i + 2 < input.size() ? input[i + 2] : 0; + const std::uint32_t n = (a << 16) | (b << 8) | c; + out.push_back(alphabet[(n >> 18) & 63]); + out.push_back(alphabet[(n >> 12) & 63]); + out.push_back(i + 1 < input.size() ? alphabet[(n >> 6) & 63] : '='); + out.push_back(i + 2 < input.size() ? alphabet[n & 63] : '='); + } + return out; +} + +std::vector base64Decode(const std::string& input) { + std::array values{}; + values.fill(-1); + const std::string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + for (int i = 0; i < static_cast(alphabet.size()); ++i) values[static_cast(alphabet[static_cast(i)])] = i; + std::vector out; + out.reserve((input.size() / 4) * 3); + std::uint32_t accumulator = 0; + int bits = 0; + for (unsigned char ch : input) { + if (ch == '=') break; + const int value = values[ch]; + if (value < 0) continue; + accumulator = (accumulator << 6) | static_cast(value); + bits += 6; + if (bits >= 8) { + bits -= 8; + out.push_back(static_cast((accumulator >> bits) & 0xFF)); + } + } + return out; +} + +int blackInkIndex(const sppm::Palette& palette) { + int bestIndex = 2; + int bestDistance = std::numeric_limits::max(); + for (int i = 1; i < static_cast(palette.size()); ++i) { + const auto& color = palette[static_cast(i)]; + if (!color.a) continue; + const int distance = static_cast(color.r) * color.r + + static_cast(color.g) * color.g + + static_cast(color.b) * color.b; + if (distance < bestDistance) { bestDistance = distance; bestIndex = i; } + } + return bestIndex; +} + +void logStartup(const std::string& message, bool reset = false) { +#ifdef __SWITCH__ + const char* path = "sdmc:/switch/Switchnote/startup.log"; +#else + const char* path = "data/startup.log"; +#endif + std::ofstream log(path, std::ios::binary | (reset ? std::ios::trunc : std::ios::app)); + if (log) log << message << '\n'; + std::fprintf(stderr, "%s\n", message.c_str()); +} + +bool endsWith(const std::string& text, const std::string& suffix) { + return text.size() >= suffix.size() && text.compare(text.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +std::string utf8Prefix(const std::string& value, std::size_t maxCharacters) { + std::size_t offset = 0; + std::size_t characters = 0; + while (offset < value.size() && characters < maxCharacters) { + const auto lead = static_cast(value[offset]); + const std::size_t bytes = lead < 0x80 ? 1 : (lead & 0xe0) == 0xc0 ? 2 : (lead & 0xf0) == 0xe0 ? 3 : (lead & 0xf8) == 0xf0 ? 4 : 1; + offset += std::min(bytes, value.size() - offset); + ++characters; + } + return value.substr(0, offset); +} + +void ensureDir(const std::string& path) { + std::string current; + current.reserve(path.size()); + auto make = [](const std::string& directory) { +#ifdef _WIN32 + _mkdir(directory.c_str()); +#else + mkdir(directory.c_str(), 0777); +#endif + }; + for (char ch : path) { + current.push_back(ch); + if (ch == '/' || ch == '\\') make(current); + } + make(current); +} + +bool copyMissingTree(const std::string& source, const std::string& destination, bool renameLegacyNotes = false) { + struct stat sourceInfo{}; + if (stat(source.c_str(), &sourceInfo) != 0) return true; + if (S_ISDIR(sourceInfo.st_mode)) { + ensureDir(destination); + DIR* directory = opendir(source.c_str()); + if (!directory) return false; + bool ok = true; + while (dirent* entry = readdir(directory)) { + const std::string name = entry->d_name; + if (name == "." || name == "..") continue; + const std::string mappedName = renameLegacyNotes && name == "Flipnotes" ? "Switchnotes" : name; + if (!copyMissingTree(join(source, name), join(destination, mappedName), false)) ok = false; + } + closedir(directory); + return ok; + } + if (!S_ISREG(sourceInfo.st_mode)) return true; + struct stat destinationInfo{}; + if (stat(destination.c_str(), &destinationInfo) == 0) return true; + std::ifstream input(source, std::ios::binary); + if (!input) return false; + const std::size_t separator = destination.find_last_of("/\\"); + if (separator != std::string::npos) ensureDir(destination.substr(0, separator)); + const std::string temporary = destination + ".migrating"; + std::remove(temporary.c_str()); + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + if (!output) return false; + std::array buffer{}; + while (input) { + input.read(buffer.data(), static_cast(buffer.size())); + const std::streamsize count = input.gcount(); + if (count > 0) output.write(buffer.data(), count); + } + output.flush(); + output.close(); + if (!input.eof() || !output) { std::remove(temporary.c_str()); return false; } + if (std::rename(temporary.c_str(), destination.c_str()) != 0) { + std::remove(temporary.c_str()); + return false; + } + return true; +} + +bool migrateLegacyStorage(const std::string& destination) { +#ifdef __SWITCH__ + const std::string marker = join(destination, "storage-migrated-v1"); + struct stat markerInfo{}; + if (stat(marker.c_str(), &markerInfo) == 0) return true; + const std::string legacy = "sdmc:/switch/FlipnoteNX"; + struct stat legacyInfo{}; + if (stat(legacy.c_str(), &legacyInfo) != 0) return true; + DIR* directory = opendir(legacy.c_str()); + if (!directory) return false; + bool ok = true; + while (dirent* entry = readdir(directory)) { + const std::string name = entry->d_name; + if (name == "." || name == "..") continue; + if (endsWith(name, ".nro")) continue; + const bool notes = name == "flipnotes"; + if (!copyMissingTree(join(legacy, name), join(destination, notes ? "switchnotes" : name), notes)) ok = false; + } + closedir(directory); + if (!ok) return false; + std::ofstream complete(marker, std::ios::binary | std::ios::trunc); + if (!complete) return false; + complete << "1\n"; + complete.flush(); + return static_cast(complete); +#else + (void)destination; + return true; +#endif +} + +std::uint64_t nowSeconds() { return static_cast(std::time(nullptr)); } + +std::string dateText(std::uint64_t seconds) { + if (!seconds) return "Unknown"; + std::time_t value = static_cast(seconds); + std::tm* tm = std::localtime(&value); + if (!tm) return "Unknown"; + char buffer[64]{}; + std::strftime(buffer, sizeof(buffer), "%B %d %Y %H:%M:%S", tm); + return buffer; +} + +std::string defaultSwitchnoteTitle(const std::string& nickname, const std::string& noteName) { + return noteName + " by " + (nickname.empty() ? noteName + " User" : nickname); +} + +bool randomBytes(void* out, std::size_t size) { +#ifdef __SWITCH__ + return R_SUCCEEDED(csrngGetRandomBytes(out, size)); +#else + try { + std::random_device rd; + auto* bytes = static_cast(out); + for (std::size_t i = 0; i < size; ++i) bytes[i] = static_cast(rd()); + return true; + } catch (...) { + return false; + } +#endif +} + +void appendIdentityMaterial(std::vector& out, std::uint8_t field, + const std::uint8_t* bytes, std::size_t length) { + out.push_back(field); + out.push_back(static_cast(length)); + out.insert(out.end(), bytes, bytes + length); +} + +bool getDeviceMaterial(std::vector& material) { + constexpr char domain[] = "FlipnoteStudio-console-binding-v2"; + material.assign(domain, domain + sizeof(domain)); +#ifdef __SWITCH__ + bool found = false; + Uuid pseudoId{}; + if (R_SUCCEEDED(appletGetPseudoDeviceId(&pseudoId))) { + appendIdentityMaterial(material, 1, pseudoId.uuid, sizeof(pseudoId.uuid)); + found = true; + } + if (R_SUCCEEDED(setcalInitialize())) { + SetCalMacAddress mac{}; + if (R_SUCCEEDED(setcalGetWirelessLanMacAddress(&mac))) { + appendIdentityMaterial(material, 2, mac.addr, sizeof(mac.addr)); + found = true; + } + u64 calibratedDeviceId = 0; + if (R_SUCCEEDED(setcalGetDeviceId(&calibratedDeviceId)) && calibratedDeviceId != 0) { + const auto* bytes = reinterpret_cast(&calibratedDeviceId); + appendIdentityMaterial(material, 3, bytes, sizeof(calibratedDeviceId)); + found = true; + } + setcalExit(); + } + if (R_SUCCEEDED(splInitialize())) { + u64 securityDeviceId = 0; + if (R_SUCCEEDED(splGetConfig(SplConfigItem_DeviceId, &securityDeviceId)) && securityDeviceId != 0) { + const auto* bytes = reinterpret_cast(&securityDeviceId); + appendIdentityMaterial(material, 5, bytes, sizeof(securityDeviceId)); + found = true; + } + splExit(); + } + if (!found) { + material.clear(); + return false; + } + return true; +#else + const char* machine = std::getenv("COMPUTERNAME"); + if (!machine || !*machine) machine = std::getenv("HOSTNAME"); + if (machine && *machine) { + appendIdentityMaterial(material, 4, reinterpret_cast(machine), std::strlen(machine)); + } else { + const std::string fallback = "local-development-profile"; + appendIdentityMaterial(material, 4, reinterpret_cast(fallback.data()), fallback.size()); + } + return true; +#endif +} + +bool readBinaryFile(const std::string& path, std::vector& bytes) { + std::ifstream input(path, std::ios::binary | std::ios::ate); + if (!input) return false; + const std::streamoff size = input.tellg(); + if (size < 0 || size > 4096) return false; + bytes.resize(static_cast(size)); + input.seekg(0, std::ios::beg); + if (!bytes.empty()) input.read(reinterpret_cast(bytes.data()), size); + return input.good() || (input.eof() && input.gcount() == size); +} + +bool loadEncryptedIdentity(const std::string& path, + const std::vector& deviceMaterial, + sppm::SigningIdentity& identity) { + constexpr std::size_t fileBytes = 4 + 16 + 24 + 32 + 16 + 128; + std::vector bytes; + if (!readBinaryFile(path, bytes) || bytes.size() != fileBytes || + std::memcmp(bytes.data(), "FNI2", 4) != 0) return false; + std::size_t at = 4; + std::array salt{}; + std::array nonce{}; + std::array sealed{}; + std::copy_n(bytes.begin() + static_cast(at), salt.size(), salt.begin()); at += salt.size(); + std::copy_n(bytes.begin() + static_cast(at), nonce.size(), nonce.begin()); at += nonce.size(); + std::copy_n(bytes.begin() + static_cast(at), identity.publicIdentity.key.size(), identity.publicIdentity.key.begin()); at += identity.publicIdentity.key.size(); + std::copy_n(bytes.begin() + static_cast(at), identity.publicIdentity.deviceTag.size(), identity.publicIdentity.deviceTag.begin()); at += identity.publicIdentity.deviceTag.size(); + std::copy_n(bytes.begin() + static_cast(at), sealed.size(), sealed.begin()); + std::vector tagMaterial = deviceMaterial; + tagMaterial.insert(tagMaterial.end(), salt.begin(), salt.end()); + if (sppm::deriveDeviceTag(tagMaterial) != identity.publicIdentity.deviceTag) return false; + const auto wrapKey = sppm::deriveDeviceWrapKey(deviceMaterial, salt); + std::string error; + if (!sppm::unsealSigningSecret(sealed, wrapKey, nonce, identity, error) || + !std::equal(identity.publicIdentity.key.begin(), identity.publicIdentity.key.end(), identity.secretKey.begin() + 32)) return false; + return sppm::hasPublicIdentity(identity.publicIdentity); +} + +bool createEncryptedIdentity(const std::string& dataRoot, + const std::vector& deviceMaterial, + const std::array& legacyKey, + sppm::SigningIdentity& identity, + std::string& pathOut) { + std::array salt{}; + std::array nonce{}; + if (!randomBytes(salt.data(), salt.size()) || !randomBytes(nonce.data(), nonce.size())) return false; + std::vector tagMaterial = deviceMaterial; + tagMaterial.insert(tagMaterial.end(), salt.begin(), salt.end()); + std::string error; + if (!sppm::createSigningIdentity(sppm::deriveDeviceTag(tagMaterial), identity, error)) return false; + identity.legacyHmacKey = legacyKey; + const auto wrapKey = sppm::deriveDeviceWrapKey(deviceMaterial, salt); + std::array sealed{}; + if (!sppm::sealSigningSecret(identity, wrapKey, nonce, sealed, error)) return false; + constexpr char hex[] = "0123456789ABCDEF"; + std::string saltName; + saltName.reserve(32); + for (std::uint8_t byte : salt) { saltName.push_back(hex[byte >> 4]); saltName.push_back(hex[byte & 15]); } + pathOut = join(dataRoot, "identity-v2-" + saltName + ".bin"); + std::vector bytes{'F', 'N', 'I', '2'}; + bytes.insert(bytes.end(), salt.begin(), salt.end()); + bytes.insert(bytes.end(), nonce.begin(), nonce.end()); + bytes.insert(bytes.end(), identity.publicIdentity.key.begin(), identity.publicIdentity.key.end()); + bytes.insert(bytes.end(), identity.publicIdentity.deviceTag.begin(), identity.publicIdentity.deviceTag.end()); + bytes.insert(bytes.end(), sealed.begin(), sealed.end()); + std::ofstream output(pathOut, std::ios::binary | std::ios::out); + if (!output) return false; + output.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + return static_cast(output); +} + +struct App { + SDL_Window* window = nullptr; + SDL_Renderer* renderer = nullptr; + SDL_GameController* controller = nullptr; + bool romfsReady = false; + bool csrngReady = false; + bool accountReady = false; + bool gallerySocketsReady = false; + bool gallerySslReady = false; + bool nifmReady = false; + bool sdlReady = false; + bool imageReady = false; + bool ttfReady = false; + bool audioReady = false; + bool appletMode = false; +#ifdef __SWITCH__ + bool irsReady = false; + bool irCameraRunning = false; + IrsIrCameraHandle irCamera{}; + Thread photoProbeThread{}; + bool photoProbeThreadCreated = false; + bool photoProbeRunning = false; + std::atomic photoProbeFinished{false}; + IrsImageTransferProcessorState irImageState{}; + std::vector irImage = std::vector(320 * 240, 0); + std::vector irPhoto = std::vector(320 * 240, 0); + std::vector irPreviewRgba = std::vector(320 * 240 * 4, 255); + SDL_Texture* irPreviewTexture = nullptr; + bool irImageReady = false; + bool irPhotoTaken = false; + bool irRotatePhoto = false; +#endif + AudioUploadServer audioUploadServer; + BrandingConfigServer brandingConfigServer; + std::string audioUploadUrl; + std::string audioUploadCode; + std::string audioUploadStatus; + bool photoTransferActive = false; + bool photoImporting = false; + bool photoProbeHasCamera = false; + std::vector photoImportRgba; + int photoImportRow = 0; + int photoImportLayer = 0; + int photoAlbumPage = 0; + bool galleryTermsAccepted = false; +#ifdef __SWITCH__ + std::vector photoAlbumEntries; + std::array photoAlbumThumbs{}; + std::array photoAlbumThumbIndexes{}; +#endif + std::string brandingUrl; + std::string brandingCode; + std::string brandingStatus; + std::string appDisplayName = "Switchnote Studio"; + std::string noteSingular = "Switchnote"; + std::string notePlural = "Switchnotes"; + std::string brandingLogoPath; + SDL_Texture* brandingLogoTexture = nullptr; + bool renderingModal = false; + int loadingDepth = 0; + std::uint32_t loadingStartedAt = 0; + std::uint32_t loadingFinishedAt = 0; + std::uint32_t completedLoadingDuration = 0; + bool loadingCompletionVisible = false; + SDL_Thread* updateThread = nullptr; + std::atomic updateWorkerDone{false}; + bool updateInProgress = false; + bool updateManualCheck = false; + bool updateOfferPending = false; + bool updateAvailable = false; + bool updateNoChange = false; + std::uint32_t updateStartedAt = 0; + UpdateTask updateTask = UpdateTask::None; + std::string updateServerVersion; + std::string updateDownloadUrl; + std::string updateSha256; + std::string updateError; + std::string updateDownloadedPath; + int updateHttpStatus = 0; + std::string launchNroPath; + std::string updateInstallSourcePath; + bool updateInstallPending = false; + std::uint32_t updateInstallReadyAt = 0; + bool photoModalTransitionDim = false; + std::uint32_t photoModalTransitionDimAt = 0; + std::array loadingFrogFrames{}; + bool running = true; + std::string assetsRoot; + std::string dataRoot; + std::string notesRoot; + std::string exportRoot; + std::string importRoot; + std::string folder = "Switchnotes"; + std::string userName = "Switchnote User"; + std::string myFsid; + std::vector profileImageBytes; + SDL_Texture* profileTexture = nullptr; + SDL_Texture* profileCornerTexture = nullptr; + SDL_Texture* profileDetailTexture = nullptr; + sppm::SigningIdentity signingIdentity{}; + std::map images; + std::map whiteImages; + std::map, TTF_Font*> fonts; + std::unordered_map textCache; + std::unordered_map hoverMotion; + std::map cues; + std::array trackChunks{}; + Mix_Chunk* bgmSeekChunk = nullptr; + std::array, 4> trackAudioBuffers{}; + std::map frameTextures; + std::uint64_t docRevision = 1; + ScreenState screen; + Modal modal = Modal::None; + Field editingField = Field::None; + std::string inputText; + std::string toast; + std::uint32_t toastUntil = 0; + std::vector library; + std::vector gallery; + std::vector galleryComments; + GalleryProfileData galleryProfile; + SDL_Texture* galleryProfileAvatarTexture = nullptr; + bool galleryProfileIsOwn = false; + Screen galleryProfileReturnScreen = Screen::Gallery; + int galleryProfilePage = 0; + bool galleryProfileActive = false; + bool galleryProfileDetailMode = false; + std::vector galleryBeforeProfile; + std::size_t gallerySelectedBeforeProfile = 0; + int galleryPageBeforeProfile = 0; + int galleryTotalBeforeProfile = 0; + std::size_t selected = 0; + std::size_t gallerySelected = 0; + int page = 0; + int galleryPage = 0; + int galleryTotal = 0; + bool galleryPopular = false; + bool galleryCommentsLoaded = false; + int galleryCommentScroll = 0; + bool galleryCommentTouchScroll = false; + int galleryCommentTouchStartY = 0; + int galleryCommentTouchStartOffset = 0; + std::uint32_t lastGalleryCommentScrollTick = 0; + std::string galleryStatus; + std::size_t pendingGalleryPublishIndex = 0; + bool galleryLoadRunning = false; + std::atomic galleryLoadFinished{false}; + std::string galleryLoadPath; + GalleryLoadKind galleryLoadKind = GalleryLoadKind::Feed; + std::vector galleryLoadResponse; + int galleryLoadHttpStatus = 0; + bool galleryLoadSucceeded = false; + std::string galleryLoadError; + bool serverStartRunning = false; + std::atomic serverStartFinished{false}; + bool serverStartSucceeded = false; + ServerStartTask serverStartTask = ServerStartTask::None; +#ifdef __SWITCH__ + Thread galleryLoadThread{}; + bool galleryLoadThreadCreated = false; + Thread serverStartThread{}; + bool serverStartThreadCreated = false; +#endif + std::string commentDraft; + std::unordered_map galleryCommentTextures; + sppm::Document doc; + bool docLoaded = false; + bool galleryViewing = false; + bool dirty = false; + std::uint32_t dirtyTick = 0; + std::string docPath; + std::size_t frame = 0; + int layer = 0; + int color = 2; + int defaultFps = 12; + int defaultSpeed = 6; + int modalSelection = 0; + Screen settingsReturnScreen = Screen::Library; + Modal lastRenderedModal = Modal::None; + Modal modalVisual = Modal::None; + bool modalClosing = false; + std::uint32_t modalTransitionStart = 0; + std::uint32_t lastScreenCueStart = 0; + int brush = 4; + int pattern = 0; + int eraserSlot = 1; + int penType = 0; + std::uint16_t penRngState = 0x1F01; + int squiggleAnchorX = -1; + int squiggleAnchorY = -1; + int ringPreviousX = -1; + int ringPreviousY = -1; + bool square = false; + bool advancedBrush = false; + Tool activeTool = Tool::Pen; + bool canvasYChordUsed = false; + bool penStabilisationEnabled = false; + bool penStabilisationAfter = false; + int penStabilisationStrength = 50; + bool stabilisedAfterReplay = false; + bool realtimeStrokeInitialized = false; + float realtimeStrokeX = 0.0f; + float realtimeStrokeY = 0.0f; + std::vector> afterStrokePoints; + bool onion = true; + bool canvasGrid = false; + bool copyToNewPage = false; + bool expandMetadata = false; + bool paintMenu = false; + bool colorMenu = false; + bool toolMenu = false; + bool canvasMenuOpen = false; + float canvasMenuProgress = 0.0f; + std::uint32_t lastCanvasMenuTick = 0; + bool playing = false; + float zoom = 1.0f; + int panX = 0; + int panY = 0; + std::uint32_t lastPlaybackTick = 0; + double playbackTickRemainder = 0.0; + sppm::Frame frameClipboard; + bool clipboardReady = false; + std::vector undoStack; + std::vector redoStack; + int audioTrack = 0; + int audioEraseArmedTrack = -1; + std::vector importFiles; + std::size_t importSelection = 0; + std::vector commentPixels = std::vector(sppm::kPixelsPerLayer, 0); + bool commentDirty = true; + SDL_Texture* commentTexture = nullptr; + + int pointerX = 0; + int pointerY = 0; + bool pointerDown = false; + bool pointerPressed = false; + bool pointerReleased = false; + bool clickConsumed = false; + bool touchHoverExpiryPending = false; + // A canvas press that began on visible chrome remains a chrome gesture for + // its entire lifetime. Once a drawing gesture starts, the chrome can move + // away without creating invisible "no draw" rectangles underneath it. + bool canvasControlGesture = false; + bool drawing = false; + bool hasLastStroke = false; + int lastStrokeX = 0; + int lastStrokeY = 0; + bool canvasTapPending = false; + bool canvasFillCommitted = false; + int canvasPressScreenX = 0; + int canvasPressScreenY = 0; + int canvasPressPixelX = 0; + int canvasPressPixelY = 0; + std::uint32_t canvasPressStartedAt = 0; + bool fillWaveActive = false; + int fillWaveOriginX = 0; + int fillWaveOriginY = 0; + float fillWaveRadius = 0.0f; + std::uint32_t fillWaveTick = 0; + std::size_t fillWaveFrame = 0; + int fillWaveLayer = -1; + std::uint8_t fillWaveColor = 0; + std::vector> fillWavePixels; + std::size_t fillWaveCursor = 0; + int wheel = 0; + std::uint32_t buttonsDown = 0; + std::uint32_t buttonsUp = 0; + std::uint32_t buttonsHeld = 0; + std::uint32_t rightDpadNextRepeatAt = 0; + bool rightDpadRepeating = false; + float pulse = 0; + float canvasChromeVisible = 1.0f; + std::uint32_t lastChromeTick = 0; + float leftStickX = 0.0f; + float leftStickY = 0.0f; + std::uint32_t lastPanTick = 0; + + bool init(int argc, char** argv); + void shutdown(); + void run(); + void poll(); + void handleKeys(); + void update(); + void startUpdateCheck(bool manual); + void startUpdateDownload(); + bool requestUpdateHandoff(); + void completeUpdateInstall(); + void runUpdateTask(); + void consumeUpdateTask(); + static int updateThreadEntry(void* context); + void render(); + void renderTitle(); + void renderAppletWarning(); + void renderLibrary(); + void renderSettings(); + void renderDetails(bool galleryMode); + void renderSequence(); + void renderCanvas(); + void renderViewer(); + void renderGallery(); + void renderGalleryProfile(); + void renderComments(); + void renderCommentCanvas(); + void renderModal(); + void drawPlayTriangle(int x, int y, int size, SDL_Color color = ORANGE); + void drawLockIcon(int x, int y); + void renderToast(); + void renderLoadingOverlay(); + void beginLoading(); + void endLoading(); + void beginFillWave(int x, int y); + void updateFillWave(std::uint32_t now); + void background(); + SDL_Texture* image(const std::string& relative); + SDL_Texture* whiteImage(const std::string& relative); + SDL_Texture* loadBrandingLogo(const std::string& path); + void drawImage(const std::string& relative, int x, int y, int w, int h, bool nearest = false); + void drawTexture(SDL_Texture* texture, int x, int y, int w, int h); + TTF_Font* font(int px, bool bold); + std::string brandString(const std::string& label) const; + void rawText(const std::string& label, int x, int y, int px, SDL_Color color, bool bold); + void text(const std::string& label, int x, int y, int px, SDL_Color color, bool bold = false); + void centered(const std::string& label, int x, int y, int w, int h, int px, SDL_Color color, bool bold = false); + void fill(int x, int y, int w, int h, SDL_Color c); + void rounded(int x, int y, int w, int h, int radius, SDL_Color c); + void roundedOutline(int x, int y, int w, int h, int radius, int thickness, SDL_Color c); + bool hit(int x, int y, int w, int h); + int hoverLift(int x, int y, bool hovered); + bool pill(const std::string& label, int x, int y, int w, int h, bool green = false, int px = 22, bool enabled = true, const std::string& cue = "button_on"); + bool actionPill(const std::string& icon, const std::string& label, int x, int y, int w, int h, bool green = false, bool enabled = true, const std::string& cue = "button_on"); + bool dangerPill(const std::string& label, int x, int y, int w, int h, int px = 18, bool enabled = true); + bool dangerActionPill(const std::string& icon, const std::string& label, int x, int y, int w, int h, int px = 19, bool enabled = true); + bool imagePill(const std::string& icon, int x, int y, int w, int h, bool green = false, bool enabled = true, const std::string& cue = "button_on"); + bool imageButton(const std::string& path, int x, int y, int w, int h, const std::string& cue = "button_on"); + void showToast(const std::string& message); + void playCue(const std::string& name); + void playEvent(const std::string& event, const std::string& defaultCue); + void loadPreferences(); + void savePreferences(); + + bool loadIdentity(); + std::string newId(const char* domain); + void scanLibrary(); + void scanImports(); + void enterGallery(); + void showGalleryNotice(); + void confirmGalleryLeave(); + void refreshGallery(); + void loadGalleryProfile(const std::string& identity, Screen returnScreen, int pageIndex = 0); + void leaveGalleryProfile(); + void finishGalleryRefresh(const std::vector& response, int status, const std::string& error); + void finishGalleryProfile(const std::vector& response, int status, const std::string& error); + void saveGalleryProfile(bool quiet = false); + void updateGalleryRelationship(const char* action); + void consumeGalleryLoad(); + bool internetConnected(); +#ifdef __SWITCH__ + static void galleryLoadThreadEntry(void* argument); + void runGalleryLoadThread(); +#endif + void refreshGalleryComments(); + void openGalleryComments(); + void openItem(std::size_t index, bool edit); + void openGalleryItem(std::size_t index); + void saveGalleryItem(std::size_t index); + void publishGalleryItem(std::size_t index); + void performGalleryUpload(std::size_t index, bool locked); + void addGalleryCoin(); + void deleteGalleryItem(); + void postGalleryComment(); + SDL_Texture* galleryPreviewTexture(const Item& item); + SDL_Texture* galleryCommentTexture(const GalleryComment& comment, std::size_t index); + bool galleryRequest(const std::string& method, const std::string& path, + const std::vector& requestBody, + const std::string& contentType, + std::vector& responseBody, int& status, + std::string& error, std::string* responseHeaders = nullptr); + bool loadSample(const Item& item, sppm::Document& out); + bool startAudioUploadServer(bool photoTransfer = false); + void requestAudioUploadServer(bool photoTransfer = false); + void stopAudioUploadServer(); + void closeAudioMenu(); + void consumeAudioUpload(); + bool startBrandingConfigServer(); + void requestServerStart(ServerStartTask task); + void requestBrandingConfigServer(); + void consumeServerStart(); + void consumePhotoProbe(); +#ifdef __SWITCH__ + static void serverStartThreadEntry(void* argument); + void runServerStartThread(); + static void photoProbeThreadEntry(void* argument); + void runPhotoProbeThread(); +#endif + void stopBrandingConfigServer(); + void closeBrandingMenu(); + void consumeBrandingConfig(); + void importBrandingConfig(); + void exportBrandingConfig(); + void newDocument(); + bool saveDocument(bool force = false); + void closeDocument(); + void deleteDocument(); + void forkDocument(); + void markDirty(); + void invalidateFrames(); + SDL_Texture* frameTexture(std::size_t index); + SDL_Texture* thumbTexture(const Item& item); + void drawCurrentFrame(int x, int y, int w, int h, bool withOnion = false); + void drawFrameImage(std::size_t index, int x, int y, int w, int h); + void drawDocumentControls(bool galleryColor = false); + void playControl(int x, int y, int w, int h); + void setFrame(std::size_t index, bool playNavigationCue = true, const char* event = nullptr); + void startPlayback(); + void stopPlayback(bool playStopCue = true); + void updatePlayback(std::uint32_t now); + void playFrameSound(); + void startBgmAtCurrentFrame(); + void rebuildAudio(); + void insertFrame(bool paste); + void eraseFrame(); + void copyFrame(); + void undo(); + void redo(); + void pushUndo(); + bool paintPixel(int sx, int sy, std::vector& pixels); + void strokeAt(int sx, int sy, bool comment = false); + void finishAfterStroke(); + void beginText(Field field, const std::string& current); + void applyText(); + void importAudio(); + bool importAudioFile(const std::string& path, int track, const std::string& displayName); + void exportDocument(int destination); + void openPhotoCapture(); + void closePhotoCapture(); + void updatePhotoCapture(); + void importPhoto(); + void openPhotoUpload(); + void openPhotoAlbum(); + void importPhotoAlbumEntry(std::size_t index); + bool importUploadedPhoto(); + void advancePhotoImport(); +}; + +struct LoadingScope { + App& app; + explicit LoadingScope(App& target) : app(target) { app.beginLoading(); } + ~LoadingScope() { app.endLoading(); } +}; + +enum ButtonBits : std::uint32_t { + BA = 1u << 0, BB = 1u << 1, BX = 1u << 2, BY = 1u << 3, + BL = 1u << 4, BR = 1u << 5, BZL = 1u << 6, BZR = 1u << 7, + BLEFT = 1u << 8, BRIGHT = 1u << 9, BUP = 1u << 10, BDOWN = 1u << 11, + BPLUS = 1u << 12, BMINUS = 1u << 13, BLS = 1u << 14, BRS = 1u << 15, BMISC = 1u << 16 +}; + +std::uint32_t controllerBit(std::uint8_t b) { + switch (b) { + // SDL names the standardized Xbox-position buttons; Nintendo labels + // put A on the right, B on the bottom, X on top and Y on the left. + case SDL_CONTROLLER_BUTTON_B: return BA; + case SDL_CONTROLLER_BUTTON_A: return BB; + case SDL_CONTROLLER_BUTTON_Y: return BX; + case SDL_CONTROLLER_BUTTON_X: return BY; + case SDL_CONTROLLER_BUTTON_LEFTSHOULDER: return BL; + case SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: return BR; + case SDL_CONTROLLER_BUTTON_DPAD_LEFT: return BLEFT; + case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: return BRIGHT; + case SDL_CONTROLLER_BUTTON_DPAD_UP: return BUP; + case SDL_CONTROLLER_BUTTON_DPAD_DOWN: return BDOWN; + case SDL_CONTROLLER_BUTTON_START: return BPLUS; + case SDL_CONTROLLER_BUTTON_BACK: return BMINUS; + case SDL_CONTROLLER_BUTTON_LEFTSTICK: return BLS; + case SDL_CONTROLLER_BUTTON_RIGHTSTICK: return BRS; + case SDL_CONTROLLER_BUTTON_MISC1: return BMISC; + default: return 0; + } +} + +} // namespace + +int main(int argc, char** argv) { + App app; + if (!app.init(argc, argv)) { app.shutdown(); return 1; } + app.run(); + app.shutdown(); + return 0; +} + +namespace { + +bool App::init(int argc, char** argv) { + logStartup("startup: entering init", true); +#ifdef __SWITCH__ + if (argc > 0 && argv && argv[0]) launchNroPath = argv[0]; + logStartup("startup: loader NRO path=" + (launchNroPath.empty() ? std::string("") : launchNroPath)); + assetsRoot = "romfs:"; + dataRoot = "sdmc:/switch/Switchnote"; + const Result romfsResult = romfsInit(); + if (R_FAILED(romfsResult)) { + logStartup("startup: romfsInit failed: " + std::to_string(static_cast(romfsResult))); + return false; + } + romfsReady = true; + logStartup("startup: romfs ready"); + appletMode = appletGetAppletType() == AppletType_LibraryApplet; + if (appletMode) logStartup("startup: LibraryApplet mode detected; showing compatibility warning"); + else { + // libnx mounts sdmc before main() as part of its default runtime startup. + const Result csrngResult = csrngInitialize(); + if (R_FAILED(csrngResult)) { + logStartup("startup: csrngInitialize failed: " + std::to_string(static_cast(csrngResult))); + return false; + } + csrngReady = true; + const Result accountResult = accountInitialize(AccountServiceType_Application); + logStartup("profile: accountInitialize=" + std::to_string(static_cast(accountResult))); + if (R_SUCCEEDED(accountResult)) { + accountReady = true; + std::vector candidates; + const auto addCandidate = [&candidates](const AccountUid& uid) { + if (!accountUidIsValid(&uid)) return; + const auto duplicate = std::find_if(candidates.begin(), candidates.end(), [&uid](const AccountUid& item) { + return item.uid[0] == uid.uid[0] && item.uid[1] == uid.uid[1]; + }); + if (duplicate == candidates.end()) candidates.push_back(uid); + }; + AccountUid uid{}; + const Result preselectedResult = accountGetPreselectedUser(&uid); + if (R_SUCCEEDED(preselectedResult)) addCandidate(uid); + logStartup("profile: preselected-user=" + std::to_string(static_cast(preselectedResult))); + uid = {}; + const Result lastOpenedResult = accountGetLastOpenedUser(&uid); + if (R_SUCCEEDED(lastOpenedResult)) addCandidate(uid); + logStartup("profile: last-opened-user=" + std::to_string(static_cast(lastOpenedResult))); + uid = {}; + const Result implicitResult = accountTrySelectUserWithoutInteraction(&uid, false); + if (R_SUCCEEDED(implicitResult)) addCandidate(uid); + logStartup("profile: implicit-user=" + std::to_string(static_cast(implicitResult))); + std::array users{}; + s32 actualUserCount = 0; + const Result listResult = accountListAllUsers(users.data(), ACC_USER_LIST_SIZE, &actualUserCount); + if (R_SUCCEEDED(listResult)) { + for (s32 i = 0; i < std::clamp(actualUserCount, 0, ACC_USER_LIST_SIZE); ++i) addCandidate(users[static_cast(i)]); + } + logStartup("profile: user-list=" + std::to_string(static_cast(listResult)) + + " count=" + std::to_string(actualUserCount)); + + for (std::size_t candidateIndex = 0; candidateIndex < candidates.size(); ++candidateIndex) { + AccountProfile profile{}; + const Result profileResult = accountGetProfile(&profile, candidates[candidateIndex]); + if (R_FAILED(profileResult)) { + logStartup("profile: get-profile failed candidate=" + std::to_string(candidateIndex) + + " result=" + std::to_string(static_cast(profileResult))); + continue; + } + AccountProfileBase profileBase{}; + const Result profileDataResult = accountProfileGet(&profile, nullptr, &profileBase); + if (R_FAILED(profileDataResult)) { + logStartup("profile: profile-data failed candidate=" + std::to_string(candidateIndex) + + " result=" + std::to_string(static_cast(profileDataResult))); + accountProfileClose(&profile); + continue; + } + profileBase.nickname[sizeof(profileBase.nickname) - 1] = '\0'; + const bool nicknameFound = profileBase.nickname[0] != '\0'; + if (nicknameFound) userName = profileBase.nickname; + + u32 imageSize = 0; + const Result imageSizeResult = accountProfileGetImageSize(&profile, &imageSize); + if (R_SUCCEEDED(imageSizeResult) && imageSize > 0 && imageSize <= 4U * 1024U * 1024U) { + std::vector jpeg(imageSize); + u32 loadedSize = 0; + const Result imageLoadResult = accountProfileLoadImage(&profile, jpeg.data(), jpeg.size(), &loadedSize); + if (R_SUCCEEDED(imageLoadResult) && loadedSize > 0 && loadedSize <= jpeg.size()) { + jpeg.resize(loadedSize); + profileImageBytes = std::move(jpeg); + } else { + logStartup("profile: image-load failed candidate=" + std::to_string(candidateIndex) + + " result=" + std::to_string(static_cast(imageLoadResult))); + } + } else { + logStartup("profile: image-size unavailable candidate=" + std::to_string(candidateIndex) + + " result=" + std::to_string(static_cast(imageSizeResult)) + + " size=" + std::to_string(imageSize)); + } + accountProfileClose(&profile); + if (nicknameFound || !profileImageBytes.empty()) { + logStartup("profile: loaded system profile candidate=" + std::to_string(candidateIndex) + + (profileImageBytes.empty() ? " (nickname only)" : " (nickname and image)")); + break; + } + } + if (userName == "Switchnote User" && profileImageBytes.empty()) logStartup("profile: no usable system profile; using fallback"); + } + } +#else + assetsRoot = "romfs"; + dataRoot = "data"; +#endif + if (!appletMode) { + notesRoot = join(dataRoot, "switchnotes"); + exportRoot = join(dataRoot, "exports"); + importRoot = join(dataRoot, "import"); + ensureDir(dataRoot); + if (!migrateLegacyStorage(dataRoot)) { + logStartup("startup: could not migrate existing Switchnote data"); + return false; + } + ensureDir(notesRoot); + ensureDir(exportRoot); + ensureDir(importRoot); + ensureDir(join(notesRoot, folder)); + const std::string updatePrefix = join(join(dataRoot, "updates"), "Switchnote-"); + const std::string updateSuffix = ".nro"; + const std::string canonicalLaunchPath = canonicalSdPath(launchNroPath); + const std::string canonicalUpdatePrefix = canonicalSdPath(updatePrefix); + if (argc > 1 && argv && argv[1] && std::string(argv[1]) == "--switchnote-update-handoff" && + canonicalLaunchPath.rfind(canonicalUpdatePrefix, 0) == 0 && + canonicalLaunchPath.size() > canonicalUpdatePrefix.size() + updateSuffix.size() && + canonicalLaunchPath.compare(canonicalLaunchPath.size() - updateSuffix.size(), updateSuffix.size(), updateSuffix) == 0 && + validNroFile(launchNroPath)) { + updateInstallPending = true; + updateInstallSourcePath = launchNroPath; + logStartup("update: launched verified NRO from update folder; install handoff armed"); + } + if (!loadIdentity()) { + logStartup("startup: identity creation failed"); + return false; + } + loadPreferences(); + logStartup("startup: identity ready"); + } + + SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "linear"); + const Uint32 sdlFlags = SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK | + (appletMode ? 0U : SDL_INIT_AUDIO); + if (SDL_Init(sdlFlags) != 0) { + logStartup("startup: SDL_Init failed: " + std::string(SDL_GetError())); + return false; + } + sdlReady = true; + const int imageFlags = IMG_Init(IMG_INIT_PNG | IMG_INIT_JPG); + imageReady = true; + if ((imageFlags & IMG_INIT_PNG) == 0) { + logStartup("startup: SDL_image PNG initialization failed: " + std::string(IMG_GetError())); + return false; + } + if (TTF_Init() != 0) { + logStartup("startup: TTF_Init failed: " + std::string(TTF_GetError())); + return false; + } + ttfReady = true; + window = SDL_CreateWindow("Switchnote Studio", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, + W, H, SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE); + if (!window) { logStartup("startup: SDL_CreateWindow failed: " + std::string(SDL_GetError())); return false; } + SDL_SetWindowTitle(window, appDisplayName.c_str()); + renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC); + if (!renderer) renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_SOFTWARE); + if (!renderer) { logStartup("startup: SDL_CreateRenderer failed: " + std::string(SDL_GetError())); return false; } + SDL_RenderSetLogicalSize(renderer, W, H); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + if (appletMode) screen.active = screen.target = Screen::AppletWarning; + // Decode the complete frog loop before the first real loading event. Lazy + // PNG decoding here caused a hitch precisely when the overlay was needed. + if (!appletMode) { + for (std::size_t i = 0; i < loadingFrogFrames.size(); ++i) { + const std::string customFrame = join(dataRoot, "branding-loading" + std::to_string(i) + ".png"); + std::ifstream custom(customFrame, std::ios::binary); + loadingFrogFrames[i] = custom ? loadBrandingLogo(customFrame) + : image("ui/loading/frog" + std::to_string(i) + ".png"); + } + const std::string titleLogoPath = brandingLogoPath.empty() + ? join(assetsRoot, "art/switchnotedefault.png") : brandingLogoPath; + if (!titleLogoPath.empty()) { + brandingLogoTexture = loadBrandingLogo(titleLogoPath); + if (!brandingLogoTexture) logStartup("title logo could not be decoded: " + titleLogoPath + " :: " + IMG_GetError()); + } + } + if (!profileImageBytes.empty() && profileImageBytes.size() <= static_cast(std::numeric_limits::max())) { + SDL_RWops* stream = SDL_RWFromConstMem(profileImageBytes.data(), static_cast(profileImageBytes.size())); + SDL_Surface* source = stream ? IMG_Load_RW(stream, 1) : nullptr; + if (!source) logStartup("profile: JPEG decode failed: " + std::string(IMG_GetError())); + auto createProfileTexture = [&](int edge) -> SDL_Texture* { + SDL_Surface* avatar = source ? SDL_CreateRGBSurfaceWithFormat(0, edge, edge, 32, SDL_PIXELFORMAT_RGBA32) : nullptr; + if (!avatar) return nullptr; + if (SDL_BlitScaled(source, nullptr, avatar, nullptr) != 0) { SDL_FreeSurface(avatar); return nullptr; } + if (SDL_MUSTLOCK(avatar)) SDL_LockSurface(avatar); + constexpr float radius = 3.0f; + for (int y = 0; y < avatar->h; ++y) { + auto* row = reinterpret_cast(static_cast(avatar->pixels) + y * avatar->pitch); + for (int x = 0; x < avatar->w; ++x) { + const bool left = x < static_cast(radius); + const bool right = x >= avatar->w - static_cast(radius); + const bool top = y < static_cast(radius); + const bool bottom = y >= avatar->h - static_cast(radius); + if (!((left || right) && (top || bottom))) continue; + const float cx = left ? radius : static_cast(avatar->w) - radius; + const float cy = top ? radius : static_cast(avatar->h) - radius; + const float dx = static_cast(x) + 0.5f - cx; + const float dy = static_cast(y) + 0.5f - cy; + const float edgeCoverage = std::clamp(radius + 0.5f - std::sqrt(dx * dx + dy * dy), 0.0f, 1.0f); + std::uint8_t r, g, b, a; + SDL_GetRGBA(row[x], avatar->format, &r, &g, &b, &a); + a = static_cast(static_cast(a) * edgeCoverage + 0.5f); + row[x] = SDL_MapRGBA(avatar->format, r, g, b, a); + } + } + if (SDL_MUSTLOCK(avatar)) SDL_UnlockSurface(avatar); + SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, avatar); + SDL_FreeSurface(avatar); + if (texture) { + SDL_SetTextureScaleMode(texture, SDL_ScaleModeLinear); + SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND); + } + return texture; + }; + profileTexture = createProfileTexture(84); + profileCornerTexture = createProfileTexture(58); + profileDetailTexture = createProfileTexture(32); + if (source && !profileTexture) logStartup("profile: avatar texture creation failed: " + std::string(SDL_GetError())); + if (source) SDL_FreeSurface(source); + } + if (!appletMode && Mix_OpenAudio(48000, AUDIO_S16SYS, 2, 4096) == 0) { + audioReady = true; + Mix_AllocateChannels(52); + const std::array, 24> soundMap{{ + {"button_on", "BUTTON_ON"}, {"button_off", "BUTTON_OFF"}, + {"cursor_cancel", "CURSOR_CANCEL"}, {"undo", "UNDO"}, {"redo", "REDO"}, + {"page_new", "EDIT_PAGE_INSERT"}, {"movie_play", "MOVIE_PLAY"}, {"movie_stop", "MOVIE_STOP"}, + {"draw_tool_btn_on", "DRAW_TOOL_BTN_ON"}, {"folder_open", "FOLDER_OPEN"}, + {"pic_stamp", "PIC_STAMP"}, {"paint", "PAINT"}, + {"page_backward", "PAGE_BACKWARD"}, {"page_forward", "PAGE_FORWARD"}, + {"page_first", "PAGE_FIRST"}, {"page_end", "PAGE_END"}, + {"edit_page_copy", "EDIT_PAGE_COPY"}, {"edit_page_paste", "EDIT_PAGE_PASTE"}, + {"edit_page_remove", "EDIT_PAGE_REMOVE"}, {"screen_swap", "SCREEN_SWAP"}, + {"popup", "POPUP"}, {"popup_select", "POPUP_SELECT"}, {"save_ok", "SAVE_OK"}, + {"delete_complete", "DELETE_COMPLETE"} + }}; + int loadedCues = 0; + for (const auto& cue : soundMap) { + const std::string path = join(assetsRoot, std::string("sfx/") + cue.second + ".wav"); + std::ifstream optionalCue(path, std::ios::binary); + if (!optionalCue) continue; + optionalCue.close(); + cues[cue.first] = Mix_LoadWAV(path.c_str()); + if (cues[cue.first]) ++loadedCues; + else logStartup("audio cue load failed: " + path + " :: " + Mix_GetError()); + } + logStartup("audio ready: " + std::to_string(loadedCues) + "/" + std::to_string(soundMap.size()) + " cues loaded"); + } else if (!appletMode) logStartup("audio open failed: " + std::string(Mix_GetError())); + for (int i = 0; i < SDL_NumJoysticks(); ++i) { + if (SDL_IsGameController(i)) { + controller = SDL_GameControllerOpen(i); + if (controller) break; + } + } + if (!appletMode) { + gallery.clear(); + scanLibrary(); + scanImports(); + screen.beginTitleIntro(); + if (updateInstallPending) updateInstallReadyAt = SDL_GetTicks() + 500; + else startUpdateCheck(false); + } else { + screen.active = screen.target = Screen::AppletWarning; + screen.phase = ScreenState::Phase::Idle; + } + logStartup("startup: initialization complete"); + return true; +} + +void App::shutdown() { + if (updateThread) { + SDL_WaitThread(updateThread, nullptr); + updateThread = nullptr; + } +#ifdef __SWITCH__ + if (serverStartThreadCreated) { + threadWaitForExit(&serverStartThread); + threadClose(&serverStartThread); + serverStartThreadCreated = false; + } + if (galleryLoadThreadCreated) { + threadWaitForExit(&galleryLoadThread); + threadClose(&galleryLoadThread); + galleryLoadThreadCreated = false; + } + if (photoProbeThreadCreated) { + threadWaitForExit(&photoProbeThread); + threadClose(&photoProbeThread); + photoProbeThreadCreated = false; + } +#endif + if (dirty && docLoaded) saveDocument(); + stopAudioUploadServer(); + stopBrandingConfigServer(); + stopPlayback(); + closePhotoCapture(); +#ifdef __SWITCH__ + if (irPreviewTexture) SDL_DestroyTexture(irPreviewTexture); + for (SDL_Texture* texture : photoAlbumThumbs) if (texture) SDL_DestroyTexture(texture); +#endif + for (SDL_Texture* texture : loadingFrogFrames) if (texture) SDL_DestroyTexture(texture); + for (auto& it : frameTextures) SDL_DestroyTexture(it.second.texture); + frameTextures.clear(); + if (commentTexture) SDL_DestroyTexture(commentTexture); + if (profileTexture) SDL_DestroyTexture(profileTexture); + if (profileCornerTexture) SDL_DestroyTexture(profileCornerTexture); + if (profileDetailTexture) SDL_DestroyTexture(profileDetailTexture); + if (galleryProfileAvatarTexture) SDL_DestroyTexture(galleryProfileAvatarTexture); + if (brandingLogoTexture) SDL_DestroyTexture(brandingLogoTexture); + for (auto& it : galleryCommentTextures) if (it.second) SDL_DestroyTexture(it.second); + galleryCommentTextures.clear(); + for (auto& it : images) SDL_DestroyTexture(it.second); + for (auto& it : whiteImages) if (it.second) SDL_DestroyTexture(it.second); + for (auto& it : textCache) SDL_DestroyTexture(it.second); + for (auto& it : fonts) TTF_CloseFont(it.second); + for (auto& it : cues) if (it.second) Mix_FreeChunk(it.second); + for (auto* chunk : trackChunks) if (chunk) Mix_FreeChunk(chunk); + if (controller) SDL_GameControllerClose(controller); + if (audioReady) Mix_CloseAudio(); + if (renderer) SDL_DestroyRenderer(renderer); + if (window) SDL_DestroyWindow(window); + if (ttfReady) TTF_Quit(); + if (imageReady) IMG_Quit(); + if (sdlReady) SDL_Quit(); +#ifdef __SWITCH__ + if (gallerySslReady) sslExit(); + if (gallerySocketsReady) socketExit(); + if (nifmReady) nifmExit(); + if (accountReady) accountExit(); + if (csrngReady) csrngExit(); + if (romfsReady) romfsExit(); +#endif +} + +void App::beginLoading() { + if (loadingDepth++ == 0) { + loadingStartedAt = SDL_GetTicks(); + loadingCompletionVisible = false; + } +} + +void App::endLoading() { + if (loadingDepth <= 0) return; + if (--loadingDepth == 0) { + completedLoadingDuration = SDL_GetTicks() - loadingStartedAt; + if (completedLoadingDuration >= 1000) { + loadingFinishedAt = SDL_GetTicks(); + loadingCompletionVisible = true; + } + } +} + +void App::renderLoadingOverlay() { + const std::uint32_t now = SDL_GetTicks(); + if (photoModalTransitionDim) { + const std::uint32_t transitionElapsed = now - photoModalTransitionDimAt; + if (transitionElapsed < 125) { + const Uint8 alpha = static_cast(std::lround(72.0f * (1.0f - transitionElapsed / 125.0f))); + fill(0, 0, W, H, SDL_Color{18, 20, 28, alpha}); + } else photoModalTransitionDim = false; + } + if (loadingDepth <= 0 && !loadingCompletionVisible) return; + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + const bool completed = loadingDepth <= 0; + const std::uint32_t completionElapsed = completed ? now - loadingFinishedAt : 0; + const std::uint32_t elapsed = completed ? completedLoadingDuration : now - loadingStartedAt; + if (!updateInProgress && elapsed < 1000) return; + if (completed && completionElapsed >= 800) { loadingCompletionVisible = false; return; } + // Keep the scene undimmed while a task is merely pending. Once the frog + // appears, dim behind it so loading feedback is visible without obscuring + // ordinary modal interactions. + fill(0, 0, W, H, SDL_Color{18, 20, 28, 72}); + constexpr std::uint32_t frameMs = 90; + const std::size_t frameIndex = static_cast((now / frameMs) % loadingFrogFrames.size()); + float progress = updateInProgress ? 1.0f : std::clamp((elapsed - 1000) / 220.0f, 0.0f, 1.0f); + if (completed) { + progress = completionElapsed < 200 ? completionElapsed / 200.0f + : completionElapsed < 600 ? 1.0f : 1.0f - (completionElapsed - 600) / 200.0f; + } + const float eased = 1.0f - std::pow(1.0f - progress, 3.0f); + SDL_Texture* frog = loadingFrogFrames[frameIndex]; + if (!frog) { + const std::string asset = "ui/loading/frog" + std::to_string(frameIndex) + ".png"; + frog = image(asset); + loadingFrogFrames[frameIndex] = frog; + } + if (frog) { + int sourceW = 0, sourceH = 0; + SDL_QueryTexture(frog, nullptr, nullptr, &sourceW, &sourceH); + const float scale = sourceW > 0 && sourceH > 0 ? std::min(64.0f / sourceW, 64.0f / sourceH) : 1.0f; + const int drawW = std::max(1, static_cast(std::lround(sourceW * scale))); + const int drawH = std::max(1, static_cast(std::lround(sourceH * scale))); + const int x = W - drawW + static_cast(std::lround((1.0f - eased) * drawW)); + drawTexture(frog, x, H - drawH - 14, drawW, drawH); + } +} + +bool App::loadIdentity() { + std::vector hardware; + if (!getDeviceMaterial(hardware)) { + logStartup("identity: no console-specific device identifier is available"); + return false; + } + + // Keep old HMAC-signed .sppm files readable, but move this compatibility + // key into the new authenticated, device-wrapped identity record. + std::array legacyKey{}; + const std::string legacyPath = join(dataRoot, "identity.bin"); + std::vector oldIdentity; + if (readBinaryFile(legacyPath, oldIdentity) && oldIdentity.size() == 48) { + std::copy_n(oldIdentity.begin(), legacyKey.size(), legacyKey.begin()); + } + + bool found = false; + bool legacyKeyIsWrapped = false; + if (DIR* dir = opendir(dataRoot.c_str())) { + while (dirent* entry = readdir(dir)) { + const std::string name = entry->d_name; + if (name.rfind("identity-v2-", 0) != 0 || !endsWith(name, ".bin")) continue; + if (loadEncryptedIdentity(join(dataRoot, name), hardware, signingIdentity)) { + found = true; + break; + } + } + closedir(dir); + } + if (!found) { + std::string identityPath; + if (!createEncryptedIdentity(dataRoot, hardware, legacyKey, signingIdentity, identityPath)) { + logStartup("identity: could not create encrypted console-bound signing identity"); + return false; + } + sppm::SigningIdentity check{}; + if (!loadEncryptedIdentity(identityPath, hardware, check) || + check.secretKey != signingIdentity.secretKey || check.legacyHmacKey != signingIdentity.legacyHmacKey) { + std::remove(identityPath.c_str()); + logStartup("identity: encrypted key could not be reopened after writing"); + return false; + } + signingIdentity = check; + legacyKeyIsWrapped = true; + } else { + legacyKeyIsWrapped = signingIdentity.legacyHmacKey == legacyKey; + if (std::all_of(signingIdentity.legacyHmacKey.begin(), signingIdentity.legacyHmacKey.end(), + [](std::uint8_t value) { return value == 0; }) && + std::any_of(legacyKey.begin(), legacyKey.end(), [](std::uint8_t value) { return value != 0; })) { + // Recover a pre-v2 profile created by an earlier build if it has + // the same persisted local key. Keep its plaintext source until + // a future encrypted migration can be safely confirmed. + signingIdentity.legacyHmacKey = legacyKey; + } + } + myFsid = sppm::deriveFsid(signingIdentity.publicIdentity); + if (oldIdentity.size() == 48 && legacyKeyIsWrapped) { + // The compatibility key has already been authenticated by the new + // device-bound encrypted record, so the old plaintext copy is no + // longer needed. + std::remove(legacyPath.c_str()); + } +#ifndef __SWITCH__ + std::ifstream nameFile(join(dataRoot, "profile.txt"), std::ios::binary); + if (nameFile) { + std::getline(nameFile, userName); + if (userName.empty() || userName.size() > 48) userName = "Switchnote User"; + } +#endif + return true; +} + +std::string App::newId(const char* domain) { + std::array idSecret{}; + std::array nonce{}; + if (!randomBytes(idSecret.data(), idSecret.size()) || !randomBytes(nonce.data(), nonce.size())) return {}; + return sppm::deriveFsid(idSecret, nonce, domain); +} + +void App::scanLibrary() { + library.clear(); + const std::string currentDir = join(notesRoot, folder); + ensureDir(currentDir); + if (DIR* dir = opendir(currentDir.c_str())) { + while (dirent* entry = readdir(dir)) { + const std::string name = entry->d_name; + const std::size_t marker = name.find(".sppm.bak-"); + if (marker == std::string::npos) continue; + const std::string target = join(currentDir, name.substr(0, marker + 5)); + struct stat targetStat{}; + if (stat(target.c_str(), &targetStat) != 0) { + const std::string backup = join(currentDir, name); + if (std::rename(backup.c_str(), target.c_str()) == 0) + logStartup("recovered previous Switchnote save: " + target); + } + } + closedir(dir); + } + if (DIR* dir = opendir(currentDir.c_str())) { + while (dirent* entry = readdir(dir)) { + std::string name = entry->d_name; + if (!endsWith(name, ".sppm")) continue; + sppm::Document file; + std::string error; + const std::string path = join(currentDir, name); + if (sppm::loadSignedFile(path, file, signingIdentity.legacyHmacKey, error)) { + Item item{}; + item.title = file.metadata.title.empty() ? defaultSwitchnoteTitle(userName, noteSingular) : file.metadata.title; + item.path = path; + item.author = file.metadata.authorName; + item.description = file.metadata.description; + item.originalAuthor = file.metadata.originalAuthorName; + item.authorFsid = file.metadata.authorFsid; + item.originalAuthorFsid = file.metadata.originalAuthorFsid; + item.currentId = file.metadata.currentId; + item.rootId = file.metadata.rootId; + item.createdUnixSeconds = file.metadata.createdUnixSeconds; + item.modifiedUnixSeconds = file.metadata.modifiedUnixSeconds; + item.allowSpinoffs = file.metadata.allowSpinoffs; + item.locked = file.metadata.locked; + item.ownerIdentity = sppm::identityFingerprint(file.metadata.authorIdentity); + library.push_back(std::move(item)); + } + } + closedir(dir); + } + std::sort(library.begin(), library.end(), [](const Item& a, const Item& b) { + if (a.modifiedUnixSeconds != b.modifiedUnixSeconds) return a.modifiedUnixSeconds > b.modifiedUnixSeconds; + if (a.createdUnixSeconds != b.createdUnixSeconds) return a.createdUnixSeconds > b.createdUnixSeconds; + return a.path < b.path; + }); + if (library.empty()) selected = 0; + else selected = std::min(selected, library.size() - 1); + page = static_cast(selected / 6); +} + +void App::scanImports() { + importFiles.clear(); + if (DIR* dir = opendir(importRoot.c_str())) { + while (dirent* entry = readdir(dir)) { + const std::string name = entry->d_name; + if (endsWith(name, ".wav") || endsWith(name, ".WAV")) importFiles.push_back(name); + } + closedir(dir); + } + std::sort(importFiles.begin(), importFiles.end()); + importSelection = 0; +} + +bool App::startAudioUploadServer(bool photoTransfer) { +#ifdef __SWITCH__ + if (audioUploadServer.isRunning()) return photoTransferActive == photoTransfer; + if (!gallerySocketsReady) { + const Result sockets = socketInitializeDefault(); + if (R_FAILED(sockets)) { + audioUploadStatus = "Network initialization failed."; + return false; + } + gallerySocketsReady = true; + } + if (!nifmReady) { + const Result network = nifmInitialize(NifmServiceType_User); + if (R_FAILED(network)) { + audioUploadStatus = "Could not read the Switch network address."; + return false; + } + nifmReady = true; + } + u32 rawAddress = 0; + if (R_FAILED(nifmGetCurrentIpAddress(&rawAddress))) { + audioUploadStatus = "Connect the Switch to your local Wi-Fi first."; + return false; + } + const std::uint32_t hostAddress = ntohl(rawAddress); + const bool privateAddress = (hostAddress >> 24) == 10 || + (hostAddress & 0xfff00000U) == 0xac100000U || + (hostAddress & 0xffff0000U) == 0xc0a80000U || + (hostAddress & 0xffff0000U) == 0xa9fe0000U || + (hostAddress & 0xffc00000U) == 0x64400000U; + if (!privateAddress) { + audioUploadStatus = "Connect to a private local network to upload audio."; + return false; + } + in_addr address{}; + address.s_addr = rawAddress; + char addressText[INET_ADDRSTRLEN]{}; + if (!::inet_ntop(AF_INET, &address, addressText, sizeof(addressText))) { + audioUploadStatus = "Could not format the Switch network address."; + return false; + } + // Rejection sampling keeps every six-digit code equally likely. + std::uint32_t randomValue = 0; + do { + if (!randomBytes(&randomValue, sizeof(randomValue))) { + audioUploadStatus = "Could not create a secure upload code."; + return false; + } + } while (randomValue >= 4294000000U); + const unsigned int pin = randomValue % 1000000U; + char formattedCode[8]{}; + std::snprintf(formattedCode, sizeof(formattedCode), "%03u-%03u", pin / 1000U, pin % 1000U); + const std::string code = formattedCode; + std::string error; + if (!audioUploadServer.start(addressText, importRoot, assetsRoot, code, error, photoTransfer, noteSingular)) { + audioUploadStatus = error.empty() ? "Could not start the local upload page." : error; + return false; + } + audioUploadCode = code; + audioUploadUrl = "http://" + std::string(addressText) + ":8766"; + photoTransferActive = photoTransfer; + audioUploadStatus = "Enter the code on your PC."; + return true; +#else + (void)photoTransfer; + audioUploadStatus = "The upload server is available in the Switch homebrew app."; + return false; +#endif +} + +void App::requestServerStart(ServerStartTask task) { + if (serverStartRunning || task == ServerStartTask::None) return; + serverStartTask = task; + serverStartRunning = true; + serverStartSucceeded = false; + serverStartFinished.store(false, std::memory_order_relaxed); + beginLoading(); +#ifdef __SWITCH__ + if (R_FAILED(threadCreate(&serverStartThread, serverStartThreadEntry, this, nullptr, 0x10000, 0x3B, -2))) { + serverStartRunning = false; + serverStartTask = ServerStartTask::None; + endLoading(); + showToast("Could not start the transfer service."); + return; + } + serverStartThreadCreated = true; + if (R_FAILED(threadStart(&serverStartThread))) { + threadClose(&serverStartThread); + serverStartThreadCreated = false; + serverStartRunning = false; + serverStartTask = ServerStartTask::None; + endLoading(); + showToast("Could not start the transfer service."); + } +#else + serverStartSucceeded = task == ServerStartTask::Branding + ? startBrandingConfigServer() + : startAudioUploadServer(task == ServerStartTask::Photo); + serverStartFinished.store(true, std::memory_order_release); +#endif +} + +void App::requestAudioUploadServer(bool photoTransfer) { + requestServerStart(photoTransfer ? ServerStartTask::Photo : ServerStartTask::Audio); +} + +void App::requestBrandingConfigServer() { + requestServerStart(ServerStartTask::Branding); +} + +#ifdef __SWITCH__ +void App::serverStartThreadEntry(void* argument) { + auto* app = static_cast(argument); + app->runServerStartThread(); + threadExit(); +} + +void App::runServerStartThread() { + serverStartSucceeded = serverStartTask == ServerStartTask::Branding + ? startBrandingConfigServer() + : startAudioUploadServer(serverStartTask == ServerStartTask::Photo); + serverStartFinished.store(true, std::memory_order_release); +} +#endif + +void App::consumeServerStart() { + if (!serverStartRunning || !serverStartFinished.exchange(false, std::memory_order_acquire)) return; +#ifdef __SWITCH__ + if (serverStartThreadCreated) { + threadWaitForExit(&serverStartThread); + threadClose(&serverStartThread); + serverStartThreadCreated = false; + } +#endif + const ServerStartTask completedTask = serverStartTask; + serverStartRunning = false; + serverStartTask = ServerStartTask::None; + endLoading(); + if (serverStartSucceeded) { + if (completedTask == ServerStartTask::Branding) showToast("Branding portal is ready on your local network"); + else showToast(completedTask == ServerStartTask::Photo + ? "Photo upload page is ready on your local network" + : "Sound upload page is ready on your local network"); + } else { + showToast(completedTask == ServerStartTask::Branding ? brandingStatus : audioUploadStatus); + } +} + +void App::stopAudioUploadServer() { + audioUploadServer.stop(); + photoTransferActive = false; + audioUploadUrl.clear(); + audioUploadCode.clear(); +} + +void App::closeAudioMenu() { + consumeAudioUpload(); + if (audioReady) Mix_HaltChannel(4); + stopAudioUploadServer(); + audioEraseArmedTrack = -1; + modal = Modal::None; +} + +void App::consumeAudioUpload() { + int track = -1; + if (!audioUploadServer.takeCompletedUpload(track)) return; + if (track == 4) { + if (photoImporting) return; + const bool started = importUploadedPhoto(); + if (!started) { + std::remove(join(importRoot, "photo-transfer.rgba").c_str()); + std::remove(join(importRoot, "photo-transfer.cfg").c_str()); + audioUploadServer.finishCompletedUpload(false); + audioUploadStatus = "That image could not be imported."; + } else audioUploadStatus = "Adding image to your Switchnote…"; + return; + } + const std::string path = join(importRoot, "audio-transfer-" + std::to_string(track) + ".wav"); + const std::string name = track == 0 ? "BGM" : "SE" + std::to_string(track); + const bool imported = importAudioFile(path, track, name + " from PC"); + audioEraseArmedTrack = -1; + std::remove(path.c_str()); + audioUploadServer.finishCompletedUpload(imported); + if (imported) audioUploadStatus = name + " added from your PC."; + else audioUploadStatus = "That WAV could not be added. Check its duration and format."; +} + +bool App::galleryRequest(const std::string& method, const std::string& path, + const std::vector& requestBody, + const std::string& contentType, + std::vector& responseBody, int& status, + std::string& error, std::string* responseHeaders) { + responseBody.clear(); + status = 0; + if (responseHeaders) responseHeaders->clear(); +#ifdef __SWITCH__ + if (!gallerySocketsReady) { + const Result result = socketInitializeDefault(); + if (R_FAILED(result)) { + error = "Network setup failed (" + std::to_string(static_cast(result)) + ")"; + return false; + } + gallerySocketsReady = true; + } + if (!gallerySslReady) { + const Result result = sslInitialize(1); + if (R_FAILED(result)) { + error = "Secure Gallery networking could not be initialized (" + std::to_string(static_cast(result)) + ")"; + return false; + } + gallerySslReady = true; + } + std::string authHeaders; + if ((method == "POST" && path != "/api/flipnotes") || method == "DELETE") { + std::vector challengeResponse; + int challengeStatus = 0; + std::string authError; + if (!galleryRequest("GET", "/api/auth/challenge", {}, "text/plain", + challengeResponse, challengeStatus, authError)) { + error = "Gallery authentication challenge failed: " + authError; + return false; + } + std::string nonceHex(challengeResponse.begin(), challengeResponse.end()); + while (!nonceHex.empty() && (nonceHex.back() == '\r' || nonceHex.back() == '\n')) nonceHex.pop_back(); + std::array nonce{}; + if (!hexDecode(nonceHex, nonce.data(), nonce.size())) { + error = "Gallery returned an invalid authentication challenge"; + return false; + } + std::vector signedRequest; + const std::string domain = "FlipnoteNX-Gallery-Request-v1"; + signedRequest.insert(signedRequest.end(), domain.begin(), domain.end()); + signedRequest.push_back(0); + signedRequest.insert(signedRequest.end(), method.begin(), method.end()); + signedRequest.push_back(0); + signedRequest.insert(signedRequest.end(), path.begin(), path.end()); + signedRequest.push_back(0); + signedRequest.insert(signedRequest.end(), nonce.begin(), nonce.end()); + signedRequest.push_back(0); + signedRequest.insert(signedRequest.end(), myFsid.begin(), myFsid.end()); + signedRequest.push_back(0); + signedRequest.insert(signedRequest.end(), requestBody.begin(), requestBody.end()); + std::array signature{}; + if (!sppm::signMessage(signingIdentity, + signedRequest.empty() ? nullptr : signedRequest.data(), + signedRequest.size(), signature, authError)) { + error = "Could not authenticate Gallery action: " + authError; + return false; + } + authHeaders = "X-FSNX-Nonce: " + nonceHex + "\r\n" + + "X-FSNX-FSID: " + myFsid + "\r\n" + + "X-FSNX-Key: " + hexEncode(signingIdentity.publicIdentity.key.data(), signingIdentity.publicIdentity.key.size()) + "\r\n" + + "X-FSNX-Device: " + hexEncode(signingIdentity.publicIdentity.deviceTag.data(), signingIdentity.publicIdentity.deviceTag.size()) + "\r\n" + + "X-FSNX-Signature: " + hexEncode(signature.data(), signature.size()) + "\r\n"; + std::fill(signedRequest.begin(), signedRequest.end(), 0); + } + addrinfo hints{}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + addrinfo* addresses = nullptr; + const std::string service = std::to_string(kGalleryServerPort); + if (::getaddrinfo(kGalleryServerHost, service.c_str(), &hints, &addresses) != 0 || !addresses) { + error = "Could not resolve the secure Gallery address " + std::string(kGalleryServerHost); + if (addresses) ::freeaddrinfo(addresses); + return false; + } + int fd = -1; + for (addrinfo* candidate = addresses; candidate; candidate = candidate->ai_next) { + fd = ::socket(candidate->ai_family, candidate->ai_socktype, candidate->ai_protocol); + if (fd < 0) continue; + timeval timeout{}; + timeout.tv_sec = 6; + ::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + ::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + if (::connect(fd, candidate->ai_addr, candidate->ai_addrlen) == 0) break; + ::close(fd); + fd = -1; + } + ::freeaddrinfo(addresses); + if (fd < 0) { + error = "Could not reach the secure Gallery at https://" + std::string(kGalleryServerHost) + "/nswitch/"; + return false; + } + + SslContext sslContext{}; + SslConnection sslConnection{}; + bool contextCreated = false; + bool connectionCreated = false; + bool socketAttached = false; + int sslSocketToClose = -1; + auto closeTls = [&]() { + if (connectionCreated) { + sslConnectionClose(&sslConnection); + connectionCreated = false; + } + if (contextCreated) { + sslContextClose(&sslContext); + contextCreated = false; + } + if (sslSocketToClose >= 0) { + ::close(sslSocketToClose); + sslSocketToClose = -1; + } else if (!socketAttached && fd >= 0) { + ::close(fd); + } + fd = -1; + }; + const char* tlsStage = "TLS context creation"; + Result tlsResult = sslCreateContext(&sslContext, SslVersion_TlsV12 | SslVersion_TlsV13); + contextCreated = R_SUCCEEDED(tlsResult); + if (R_SUCCEEDED(tlsResult)) { + tlsStage = "TLS connection setup"; + tlsResult = sslContextCreateConnection(&sslContext, &sslConnection); + connectionCreated = R_SUCCEEDED(tlsResult); + } + if (R_SUCCEEDED(tlsResult)) { + tlsStage = "Gallery hostname setup"; + tlsResult = sslConnectionSetHostName(&sslConnection, kGalleryServerHost, std::strlen(kGalleryServerHost)); + } + if (R_SUCCEEDED(tlsResult)) { + tlsStage = "certificate verification setup"; + tlsResult = sslConnectionSetVerifyOption(&sslConnection, + SslVerifyOption_PeerCa | SslVerifyOption_HostName | SslVerifyOption_DateCheck); + } + if (R_SUCCEEDED(tlsResult)) { + tlsStage = "secure socket setup"; + errno = 0; + sslSocketToClose = socketSslConnectionSetSocketDescriptor(&sslConnection, fd); + socketAttached = true; + if (sslSocketToClose < 0 && errno != ENOENT) tlsResult = MAKERESULT(Module_Libnx, LibnxError_BadInput); + } + if (R_SUCCEEDED(tlsResult)) { + tlsStage = "TLS handshake/certificate verification"; + tlsResult = sslConnectionDoHandshake(&sslConnection, nullptr, nullptr, nullptr, 0); + } + if (R_FAILED(tlsResult)) { + std::ostringstream detail; + detail << "Secure Gallery connection failed during " << tlsStage << " (" << static_cast(tlsResult) + << ", module " << R_MODULE(tlsResult) << ", description " << R_DESCRIPTION(tlsResult) << ")"; + if (connectionCreated && std::strcmp(tlsStage, "TLS handshake/certificate verification") == 0) { + const Result verifyResult = sslConnectionGetVerifyCertError(&sslConnection); + if (R_FAILED(verifyResult)) { + detail << " [certificate check " << static_cast(verifyResult) + << ", module " << R_MODULE(verifyResult) << ", description " << R_DESCRIPTION(verifyResult) << "]"; + } else { + detail << " [no certificate rejection reported]"; + } + } + error = detail.str(); + closeTls(); + return false; + } + const std::string type = contentType.empty() ? "application/octet-stream" : contentType; + const std::string requestPath = std::string(kGalleryServerBasePath) + path; + const auto requestNumber = galleryRequestCounter.fetch_add(1, std::memory_order_relaxed) + 1; + const std::string requestId = "snx-" + std::to_string(SDL_GetTicks()) + "-" + std::to_string(requestNumber); + const std::string headers = method + " " + requestPath + " HTTP/1.1\r\nHost: " + kGalleryServerHost + + "\r\nUser-Agent: SwitchnoteStudio/0.1.51\r\nX-Switchnote-Client: Switchnote-Studio\r\nX-Switchnote-Request-ID: " + requestId + + "\r\nConnection: close\r\nContent-Type: " + type + "\r\nContent-Length: " + + std::to_string(requestBody.size()) + "\r\n" + authHeaders + "\r\n"; + auto sendBytes = [&sslConnection](const std::uint8_t* data, std::size_t length) { + std::size_t sent = 0; + while (sent < length) { + u32 written = 0; + const std::size_t amount = std::min(length - sent, 0x4000); + const Result result = sslConnectionWrite(&sslConnection, data + sent, static_cast(amount), &written); + if (R_FAILED(result) || written == 0) return false; + sent += written; + } + return true; + }; + if (!sendBytes(reinterpret_cast(headers.data()), headers.size()) || + (!requestBody.empty() && !sendBytes(requestBody.data(), requestBody.size()))) { + error = "Could not send the Gallery request"; + closeTls(); + return false; + } + std::vector received; + received.reserve(4096); + std::array chunk{}; + constexpr std::size_t maxResponse = 72U * 1024U * 1024U; + while (true) { + u32 count = 0; + const Result result = sslConnectionRead(&sslConnection, chunk.data(), chunk.size(), &count); + if (R_FAILED(result)) { + error = "Gallery response timed out or was interrupted"; + closeTls(); + return false; + } + if (count == 0) break; + if (received.size() + count > maxResponse) { + error = "Gallery response is too large"; + closeTls(); + return false; + } + received.insert(received.end(), chunk.begin(), chunk.begin() + count); + } + closeTls(); + if (received.empty()) { error = "Gallery returned an empty response"; return false; } + const std::string headerText(reinterpret_cast(received.data()), received.size()); + const std::size_t headerEnd = headerText.find("\r\n\r\n"); + if (headerEnd == std::string::npos) { error = "Gallery returned an invalid HTTP response"; return false; } + const std::size_t statusEnd = headerText.find("\r\n"); + if (statusEnd == std::string::npos) { error = "Gallery returned an invalid HTTP status"; return false; } + const std::string statusLine = headerText.substr(0, statusEnd); + if (std::sscanf(statusLine.c_str(), "HTTP/%*s %d", &status) != 1) { + error = "Gallery returned an invalid HTTP status"; + return false; + } + const std::size_t bodyStart = headerEnd + 4; + if (responseHeaders) responseHeaders->assign(headerText.data(), headerEnd); + responseBody.assign(received.begin() + static_cast(bodyStart), received.end()); + if (status < 200 || status >= 300) { + error.assign(responseBody.begin(), responseBody.begin() + static_cast(std::min(responseBody.size(), 300))); + while (!error.empty() && (error.back() == '\r' || error.back() == '\n')) error.pop_back(); + if (error.empty()) error = "Gallery returned HTTP " + std::to_string(status); + return false; + } + error.clear(); + return true; +#else + (void)method; (void)path; (void)requestBody; (void)contentType; (void)responseHeaders; + error = "Switchnote Gallery is available from the Switch app"; + return false; +#endif +} + +void App::enterGallery() { + galleryPage = 0; + gallerySelected = 0; + galleryStatus = "Loading Gallery…"; + screen = Screen::Gallery; + refreshGallery(); +} + +void App::showGalleryNotice() { + modal = Modal::GalleryNotice; + modalTransitionStart = SDL_GetTicks(); +} + +bool App::internetConnected() { +#ifdef __SWITCH__ + if (!nifmReady) { + if (R_FAILED(nifmInitialize(NifmServiceType_User))) return false; + nifmReady = true; + } + NifmInternetConnectionType connectionType{}; + NifmInternetConnectionStatus connectionStatus{}; + u32 wifiStrength = 0; + return R_SUCCEEDED(nifmGetInternetConnectionStatus(&connectionType, &wifiStrength, &connectionStatus)) && + connectionStatus == NifmInternetConnectionStatus_Connected; +#else + return true; +#endif +} + +void App::confirmGalleryLeave() { + if (internetConnected()) { + modal = Modal::None; + enterGallery(); + } else { + modal = Modal::GalleryOffline; + modalTransitionStart = SDL_GetTicks(); + } +} + +void App::refreshGallery() { + if (galleryLoadRunning) return; + galleryLoadKind = GalleryLoadKind::Feed; + const std::string path = "/api/flipnotes?page=" + std::to_string(galleryPage) + + "&sort=" + (galleryPopular ? "popular" : "recent"); + galleryStatus = "Loading Gallery…"; +#ifdef __SWITCH__ + if (galleryLoadThreadCreated) { + threadWaitForExit(&galleryLoadThread); + threadClose(&galleryLoadThread); + galleryLoadThreadCreated = false; + } + galleryLoadPath = path; + galleryLoadFinished.store(false, std::memory_order_relaxed); + galleryLoadRunning = true; + galleryLoadResponse.clear(); + galleryLoadError.clear(); + beginLoading(); + if (R_FAILED(threadCreate(&galleryLoadThread, galleryLoadThreadEntry, this, nullptr, 0x10000, 0x3B, -2))) { + galleryLoadRunning = false; + endLoading(); + galleryStatus = "Could not start the Gallery connection."; + showToast(galleryStatus); + return; + } + galleryLoadThreadCreated = true; + if (R_FAILED(threadStart(&galleryLoadThread))) { + threadClose(&galleryLoadThread); + galleryLoadThreadCreated = false; + galleryLoadRunning = false; + endLoading(); + galleryStatus = "Could not start the Gallery connection."; + showToast(galleryStatus); + } +#else + std::vector response; + int status = 0; + std::string error; + if (!galleryRequest("GET", path, {}, "text/plain", response, status, error)) { + finishGalleryRefresh(response, status, error); + return; + } + finishGalleryRefresh(response, status, {}); +#endif +} + +void App::loadGalleryProfile(const std::string& identity, Screen returnScreen, int pageIndex) { + if (identity.size() != 64) { showToast("This Gallery user has no profile yet"); return; } + if (galleryLoadRunning) return; + if (!galleryProfileActive) { + galleryBeforeProfile = gallery; + gallerySelectedBeforeProfile = gallerySelected; + galleryPageBeforeProfile = galleryPage; + galleryTotalBeforeProfile = galleryTotal; + galleryProfileActive = true; + } + if (galleryProfile.identity != identity) galleryProfile = GalleryProfileData{}; + galleryProfile.identity = identity; + galleryProfileReturnScreen = returnScreen; + galleryProfilePage = std::max(0, pageIndex); + galleryProfileDetailMode = false; + galleryProfileIsOwn = identity == sppm::identityFingerprint(signingIdentity.publicIdentity); + galleryLoadKind = GalleryLoadKind::Profile; + galleryLoadPath = "/api/profiles/" + identity + "?viewer=" + + sppm::identityFingerprint(signingIdentity.publicIdentity); + if (galleryProfilePage > 0) galleryLoadPath += "&page=" + std::to_string(galleryProfilePage - 1); + gallery.clear(); + screen = Screen::GalleryProfile; + galleryLoadFinished.store(false, std::memory_order_relaxed); + galleryLoadRunning = true; + galleryLoadResponse.clear(); + galleryLoadError.clear(); + beginLoading(); +#ifdef __SWITCH__ + if (galleryLoadThreadCreated) { + threadWaitForExit(&galleryLoadThread); + threadClose(&galleryLoadThread); + galleryLoadThreadCreated = false; + } + if (R_FAILED(threadCreate(&galleryLoadThread, galleryLoadThreadEntry, this, nullptr, 0x10000, 0x3B, -2))) { + galleryLoadRunning = false; + endLoading(); + galleryStatus = "Could not start the Gallery connection."; + return; + } + galleryLoadThreadCreated = true; + if (R_FAILED(threadStart(&galleryLoadThread))) { + threadClose(&galleryLoadThread); + galleryLoadThreadCreated = false; + galleryLoadRunning = false; + endLoading(); + galleryStatus = "Could not start the Gallery connection."; + } +#else + int status = 0; + std::string error; + const bool ok = galleryRequest("GET", galleryLoadPath, {}, "text/plain", galleryLoadResponse, status, error); + galleryLoadRunning = false; + endLoading(); + finishGalleryProfile(galleryLoadResponse, status, ok ? std::string{} : error); +#endif +} + +void App::leaveGalleryProfile() { + if (galleryProfileDetailMode) { + galleryProfileDetailMode = false; + screen = Screen::GalleryProfile; + return; + } + if (galleryProfileActive) { + gallery = std::move(galleryBeforeProfile); + gallerySelected = gallery.empty() ? 0 : std::min(gallerySelectedBeforeProfile, gallery.size() - 1); + galleryPage = galleryPageBeforeProfile; + galleryTotal = galleryTotalBeforeProfile; + galleryProfileActive = false; + } + screen = galleryProfileReturnScreen; +} + +#ifdef __SWITCH__ +void App::galleryLoadThreadEntry(void* argument) { + auto* app = static_cast(argument); + app->runGalleryLoadThread(); + threadExit(); +} + +void App::runGalleryLoadThread() { + galleryLoadSucceeded = galleryRequest("GET", galleryLoadPath, {}, "text/plain", + galleryLoadResponse, galleryLoadHttpStatus, galleryLoadError); + galleryLoadFinished.store(true, std::memory_order_release); +} +#endif + +void App::consumeGalleryLoad() { +#ifdef __SWITCH__ + if (!galleryLoadRunning || !galleryLoadFinished.exchange(false, std::memory_order_acquire)) return; + if (galleryLoadThreadCreated) { + threadWaitForExit(&galleryLoadThread); + threadClose(&galleryLoadThread); + galleryLoadThreadCreated = false; + } + galleryLoadRunning = false; + endLoading(); + if (galleryLoadKind == GalleryLoadKind::Profile) + finishGalleryProfile(galleryLoadResponse, galleryLoadHttpStatus, + galleryLoadSucceeded ? std::string{} : galleryLoadError); + else finishGalleryRefresh(galleryLoadResponse, galleryLoadHttpStatus, + galleryLoadSucceeded ? std::string{} : galleryLoadError); +#endif +} + +void App::finishGalleryProfile(const std::vector& response, int status, const std::string& error) { + (void)status; + if (!error.empty()) { galleryStatus = error; showToast(error); return; } + std::string body(response.begin(), response.end()); + if (!body.empty() && body.back() == '\n') body.pop_back(); + std::istringstream lines(body); + std::string line; + if (!std::getline(lines, line)) line.clear(); + const auto fields = splitTabs(line); + if (fields.size() < 11 || fields[0] != "PROFILE") { + galleryStatus = "Gallery returned an invalid profile."; + showToast(galleryStatus); + return; + } + galleryProfile.username = percentDecode(fields[1]); + galleryProfile.bio = percentDecode(fields[2]); + galleryProfile.switchnotes = std::max(0, std::atoi(fields[3].c_str())); + galleryProfile.views = std::max(0, std::atoi(fields[4].c_str())); + galleryProfile.coins = std::max(0, std::atoi(fields[5].c_str())); + galleryProfile.followers = std::max(0, std::atoi(fields[6].c_str())); + galleryProfile.following = std::max(0, std::atoi(fields[7].c_str())); + galleryProfile.followed = fields[8] == "1"; + galleryProfile.blocked = fields[9] == "1"; + if (fields[10].size() < 400U * 1024U) galleryProfile.avatar = base64Decode(fields[10]); + if (galleryProfileIsOwn) { + galleryProfile.username = userName; + galleryProfile.avatar = profileImageBytes; + } + galleryProfile.uploads.clear(); + if (galleryProfilePage > 0) { + bool foundUploads = false; + while (std::getline(lines, line)) { + if (!foundUploads) { + if (line.rfind("UPLOADS\t", 0) == 0) foundUploads = true; + continue; + } + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.empty()) continue; + const auto noteFields = splitTabs(line); + if (noteFields.size() < 12) continue; + Item item{}; + item.galleryId = noteFields[0]; + item.title = percentDecode(noteFields[1]); + item.description = percentDecode(noteFields[2]); + item.author = percentDecode(noteFields[3]); + item.fsid = noteFields[4]; + item.rootId = noteFields[5]; + item.currentId = noteFields[6]; + item.modifiedUnixSeconds = static_cast(std::strtoull(noteFields[7].c_str(), nullptr, 10)); + item.coins = std::max(0, std::atoi(noteFields[8].c_str())); + item.views = std::max(0, std::atoi(noteFields[9].c_str())); + item.allowSpinoffs = noteFields[10] == "1"; + item.locked = noteFields.size() > 13 && noteFields[13] == "1"; + if (noteFields[11].size() <= 1024U * 1024U) item.previewBytes = base64Decode(noteFields[11]); + if (noteFields.size() > 12) item.ownerIdentity = noteFields[12]; + galleryProfile.uploads.push_back(std::move(item)); + } + } + gallery = galleryProfile.uploads; + galleryTotal = galleryProfile.switchnotes; + galleryStatus.clear(); + gallerySelected = gallery.empty() ? 0 : std::min(gallerySelected, gallery.size() - 1); + if (galleryProfileAvatarTexture) { + SDL_DestroyTexture(galleryProfileAvatarTexture); + galleryProfileAvatarTexture = nullptr; + } + if (!galleryProfile.avatar.empty() && galleryProfile.avatar.size() <= static_cast(std::numeric_limits::max())) { + SDL_RWops* stream = SDL_RWFromConstMem(galleryProfile.avatar.data(), static_cast(galleryProfile.avatar.size())); + SDL_Surface* surface = stream ? IMG_Load_RW(stream, 1) : nullptr; + galleryProfileAvatarTexture = surface ? SDL_CreateTextureFromSurface(renderer, surface) : nullptr; + if (surface) SDL_FreeSurface(surface); + } + if (galleryProfileIsOwn && galleryProfilePage == 0) saveGalleryProfile(true); +} + +void App::saveGalleryProfile(bool quiet) { + if (!galleryProfileIsOwn) return; + std::string body = "username=" + percentEncode(userName) + "&bio=" + percentEncode(galleryProfile.bio) + "&avatar="; + if (!profileImageBytes.empty()) body += percentEncode(base64Encode(profileImageBytes)); + std::vector request(body.begin(), body.end()), response; + int status = 0; + std::string error; + if (!galleryRequest("POST", "/api/profiles/update", request, "application/x-www-form-urlencoded", response, status, error) || status < 200 || status >= 300) { + if (!quiet) showToast(error.empty() ? "Could not save Gallery profile." : error); + return; + } + if (!quiet) showToast("Gallery profile saved"); +} + +void App::updateGalleryRelationship(const char* action) { + if (galleryProfileIsOwn || galleryProfile.identity.empty()) return; + const std::string bodyText = "action=" + std::string(action) + "&target=" + galleryProfile.identity; + std::vector body(bodyText.begin(), bodyText.end()), response; + int status = 0; + std::string error; + if (!galleryRequest("POST", "/api/profiles/relationship", body, "application/x-www-form-urlencoded", response, status, error) || status < 200 || status >= 300) { + if (error.empty()) error.assign(response.begin(), response.end()); + showToast(error.empty() ? "Gallery profile action failed." : error); + return; + } + if (std::string(action) == "follow") { galleryProfile.followed = true; ++galleryProfile.followers; } + else if (std::string(action) == "unfollow") { galleryProfile.followed = false; galleryProfile.followers = std::max(0, galleryProfile.followers - 1); } + else if (std::string(action) == "block") { galleryProfile.blocked = true; galleryProfile.followed = false; } + else if (std::string(action) == "unblock") galleryProfile.blocked = false; +} + +void App::finishGalleryRefresh(const std::vector& response, int status, const std::string& error) { + (void)status; + if (!error.empty()) { + gallery.clear(); + galleryTotal = 0; + galleryStatus = error; + showToast(error); + return; + } + for (auto it = images.begin(); it != images.end();) { + if (it->first.rfind("gallery-preview:", 0) == 0) { + if (it->second) SDL_DestroyTexture(it->second); + it = images.erase(it); + } else ++it; + } + gallery.clear(); + galleryTotal = 0; + const std::string textBody(response.begin(), response.end()); + std::istringstream lines(textBody); + std::string line; + bool firstLine = true; + while (std::getline(lines, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.empty()) continue; + if (firstLine) { + firstLine = false; + const auto header = splitTabs(line); + if (header.size() > 1 && header[0] == "TOTAL") galleryTotal = std::max(0, std::atoi(header[1].c_str())); + continue; + } + const auto fields = splitTabs(line); + if (fields.size() < 12) continue; + Item item{}; + item.galleryId = fields[0]; + item.title = percentDecode(fields[1]); + item.description = percentDecode(fields[2]); + item.author = percentDecode(fields[3]); + item.fsid = fields[4]; + item.rootId = fields[5]; + item.currentId = fields[6]; + item.modifiedUnixSeconds = static_cast(std::strtoull(fields[7].c_str(), nullptr, 10)); + item.coins = std::max(0, std::atoi(fields[8].c_str())); + item.views = std::max(0, std::atoi(fields[9].c_str())); + item.allowSpinoffs = fields[10] == "1"; + if (fields[11].size() <= 1024U * 1024U) item.previewBytes = base64Decode(fields[11]); + if (fields.size() > 12) item.ownerIdentity = fields[12]; + item.locked = fields.size() > 13 && fields[13] == "1"; + gallery.push_back(std::move(item)); + } + gallerySelected = gallery.empty() ? 0 : std::min(gallerySelected, gallery.size() - 1); + galleryCommentsLoaded = false; + galleryStatus = gallery.empty() + ? (galleryTotal > 0 ? "There are no Switchnotes on this page." : "No Switchnotes have been shared yet.") + : std::string{}; + if (!galleryTermsAccepted && screen == Screen::Gallery) { + modal = Modal::GalleryTerms; + modalTransitionStart = SDL_GetTicks(); + } +} + +void App::refreshGalleryComments() { + galleryComments.clear(); + for (auto& entry : galleryCommentTextures) if (entry.second) SDL_DestroyTexture(entry.second); + galleryCommentTextures.clear(); + if (gallery.empty()) { galleryCommentsLoaded = true; return; } + const Item& item = gallery[gallerySelected % gallery.size()]; + std::vector response; + int status = 0; + std::string error; + if (!galleryRequest("GET", "/api/flipnotes/" + item.galleryId + "/comments", {}, "text/plain", response, status, error)) { + showToast(error); + galleryCommentsLoaded = false; + return; + } + std::istringstream lines(std::string(response.begin(), response.end())); + std::string line; + bool firstLine = true; + while (std::getline(lines, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.empty()) continue; + if (firstLine) { firstLine = false; continue; } + const auto fields = splitTabs(line); + if (fields.size() < 4) continue; + GalleryComment comment; + comment.author = percentDecode(fields[0]); + comment.text = percentDecode(fields[1]); + const std::uint64_t created = static_cast(std::strtoull(fields[2].c_str(), nullptr, 10)); + comment.created = dateText(created); + if (fields[3].size() <= 450U * 1024U) comment.drawing = base64Decode(fields[3]); + if (comment.drawing.size() != 0 && comment.drawing.size() != sppm::kPixelsPerLayer) comment.drawing.clear(); + galleryComments.push_back(std::move(comment)); + } + galleryCommentsLoaded = true; +} + +void App::openGalleryComments() { + refreshGalleryComments(); + galleryCommentScroll = 0; + screen = Screen::GalleryComments; +} + +SDL_Texture* App::galleryPreviewTexture(const Item& item) { + if (item.previewBytes.empty() || item.previewBytes.size() > static_cast(std::numeric_limits::max())) return nullptr; + const std::string key = "gallery-preview:" + item.galleryId; + auto found = images.find(key); + if (found != images.end()) return found->second; + SDL_RWops* stream = SDL_RWFromConstMem(item.previewBytes.data(), static_cast(item.previewBytes.size())); + SDL_Surface* surface = stream ? IMG_Load_RW(stream, 1) : nullptr; + SDL_Texture* texture = surface ? SDL_CreateTextureFromSurface(renderer, surface) : nullptr; + if (surface) SDL_FreeSurface(surface); + if (texture) SDL_SetTextureScaleMode(texture, SDL_ScaleModeLinear); + else logStartup("gallery preview decode failed: " + std::string(IMG_GetError())); + images[key] = texture; + return texture; +} + +SDL_Texture* App::galleryCommentTexture(const GalleryComment& comment, std::size_t index) { + if (comment.drawing.size() != sppm::kPixelsPerLayer) return nullptr; + const std::string key = "gallery-comment:" + (gallery.empty() ? std::string{} : gallery[gallerySelected % gallery.size()].galleryId) + ":" + std::to_string(index); + auto found = galleryCommentTextures.find(key); + if (found != galleryCommentTextures.end()) return found->second; + SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, sppm::kWidth, sppm::kHeight, 32, SDL_PIXELFORMAT_RGBA32); + if (!surface) return nullptr; + auto* pixels = static_cast(surface->pixels); + for (std::size_t i = 0; i < comment.drawing.size(); ++i) + pixels[i] = comment.drawing[i] ? SDL_MapRGBA(surface->format, 20, 22, 28, 255) : SDL_MapRGBA(surface->format, 0, 0, 0, 0); + SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface); + SDL_FreeSurface(surface); + if (texture) { + SDL_SetTextureScaleMode(texture, SDL_ScaleModeNearest); + SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND); + } + galleryCommentTextures[key] = texture; + return texture; +} + +void App::publishGalleryItem(std::size_t index) { + if (index >= library.size()) return; + pendingGalleryPublishIndex = index; + modalTransitionStart = SDL_GetTicks(); + modal = Modal::GalleryPostChoice; +} + +void App::performGalleryUpload(std::size_t index, bool locked) { + if (index >= library.size()) return; + const Item& item = library[index]; + if (item.path.empty()) { showToast("This Switchnote cannot be sent to the Gallery"); return; } + sppm::Document upload; + std::string error; + if (docLoaded && !galleryViewing && docPath == item.path) { + if (dirty && !saveDocument(true)) return; + upload = doc; + } else if (!sppm::loadSignedFile(item.path, upload, signingIdentity.legacyHmacKey, error)) { + showToast("Could not open Switchnote: " + error); + return; + } + upload.metadata.lastEditorName = userName; + upload.metadata.lastEditorFsid = myFsid; + upload.metadata.lastEditorIdentity = signingIdentity.publicIdentity; + upload.metadata.locked = locked; + upload.metadata.modifiedUnixSeconds = nowSeconds(); + const std::string tempId = newId("gallery-upload"); + if (tempId.empty()) { showToast("Could not create a secure upload ID"); return; } + const std::string tempPath = join(dataRoot, ".gallery-upload-" + tempId + ".sppm"); + if (!sppm::savePublicSignedFile(tempPath, upload, signingIdentity, error)) { + showToast("Could not sign Switchnote for Gallery: " + error); + return; + } + std::ifstream input(tempPath, std::ios::binary | std::ios::ate); + std::streamoff size = -1; + if (input) size = static_cast(input.tellg()); + if (size <= 0 || static_cast(size) > 64U * 1024U * 1024U) { + input.close(); + std::remove(tempPath.c_str()); + showToast("Switchnote is too large to send to Gallery"); + return; + } + std::vector bytes(static_cast(size)); + input.seekg(0, std::ios::beg); + if (!input) { input.close(); std::remove(tempPath.c_str()); showToast("Could not read signed Switchnote"); return; } + input.read(reinterpret_cast(bytes.data()), static_cast(size)); + const bool readOk = input.gcount() == static_cast(bytes.size()); + input.close(); + std::remove(tempPath.c_str()); + if (!readOk) { showToast("Could not read signed Switchnote"); return; } + std::vector response; + int status = 0; + if (!galleryRequest("POST", "/api/flipnotes", bytes, "application/octet-stream", response, status, error)) { + showToast("Send failed: " + error); + return; + } + showToast("Switchnote sent to Gallery"); + enterGallery(); +} + +void App::addGalleryCoin() { + if (gallery.empty()) return; + Item& item = gallery[gallerySelected % gallery.size()]; + std::vector body, response; + int status = 0; + std::string error; + if (!galleryRequest("POST", "/api/flipnotes/" + item.galleryId + "/coins", body, + "application/x-www-form-urlencoded", response, status, error)) { + showToast(error); + return; + } + item.coins = std::max(0, std::atoi(std::string(response.begin(), response.end()).c_str())); + showToast("Coin added"); +} + +void App::deleteGalleryItem() { + if (gallery.empty()) return; + const std::string noteId = gallery[gallerySelected % gallery.size()].galleryId; + std::vector response; + int status = 0; + std::string error; + if (!galleryRequest("DELETE", "/api/flipnotes/" + noteId, {}, + "application/octet-stream", response, status, error)) { + showToast("Could not remove Switchnote: " + error); + modal = Modal::None; + return; + } + modal = Modal::None; + screen = Screen::Gallery; + if (galleryTotal > 0) --galleryTotal; + if (galleryPage > 0 && static_cast(galleryPage * 6) >= static_cast(galleryTotal)) --galleryPage; + galleryStatus = "Loading Gallery…"; + refreshGallery(); + showToast("Switchnote removed from Gallery"); +} + +void App::postGalleryComment() { + if (gallery.empty()) return; + const bool hasDrawing = std::any_of(commentPixels.begin(), commentPixels.end(), [](std::uint8_t value) { return value != 0; }); + if (commentDraft.empty() && !hasDrawing) { showToast("Write or draw a comment first"); return; } + const Item& item = gallery[gallerySelected % gallery.size()]; + std::string form = "author=" + percentEncode(userName) + "&text=" + percentEncode(commentDraft); + if (hasDrawing) form += "&drawing=" + base64Encode(commentPixels); + std::vector body(form.begin(), form.end()), response; + int status = 0; + std::string error; + if (!galleryRequest("POST", "/api/flipnotes/" + item.galleryId + "/comments", body, + "application/x-www-form-urlencoded", response, status, error)) { + showToast("Comment failed: " + error); + return; + } + commentDraft.clear(); + commentPixels.assign(sppm::kPixelsPerLayer, 0); + commentDirty = true; + refreshGalleryComments(); + screen = Screen::GalleryComments; + showToast("Comment posted"); +} + +void App::openGalleryItem(std::size_t index) { + if (gallery.empty()) return; + gallerySelected = index % gallery.size(); + const Item& item = gallery[gallerySelected]; + std::vector bytes; + int status = 0; + std::string error; + if (!galleryRequest("GET", "/api/flipnotes/" + item.galleryId + "/file", {}, "application/octet-stream", bytes, status, error)) { + showToast("Could not download Switchnote: " + error); + return; + } + sppm::Document downloaded; + if (!sppm::deserializeSigned(bytes, downloaded, signingIdentity.legacyHmacKey, error)) { + showToast("Gallery Switchnote failed verification: " + error); + return; + } + if (docLoaded && dirty && !galleryViewing && !saveDocument(true)) return; + stopPlayback(); + doc = std::move(downloaded); + docLoaded = true; + docPath.clear(); + galleryViewing = true; + dirty = false; + frame = 0; + zoom = 1.0f; + panX = panY = 0; + invalidateFrames(); + rebuildAudio(); + screen = Screen::Viewer; +} + +void App::saveGalleryItem(std::size_t index) { + LoadingScope loading(*this); + if (index >= gallery.size()) return; + const Item& item = gallery[index]; + for (const Item& local : library) { + if ((!item.currentId.empty() && local.currentId == item.currentId) || + (!item.fsid.empty() && local.fsid == item.fsid)) { + showToast("This Switchnote is already saved locally"); + return; + } + } + + std::vector bytes, response; + int status = 0; + std::string error; + if (!galleryRequest("GET", "/api/flipnotes/" + item.galleryId + "/file", {}, + "application/octet-stream", bytes, status, error)) { + showToast("Could not save Switchnote: " + error); + return; + } + sppm::Document verified; + if (!sppm::deserializeSigned(bytes, verified, signingIdentity.legacyHmacKey, error)) { + showToast("Gallery Switchnote failed verification: " + error); + return; + } + + const std::string directory = join(notesRoot, folder); + ensureDir(directory); + const std::string saveId = newId("gallery-save"); + if (saveId.empty()) { showToast("Could not create a secure local filename"); return; } + const std::string destination = join(directory, saveId + ".sppm"); + const std::string temporary = destination + ".saving"; + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + if (!output) { showToast("Could not save Switchnote to the SD card"); return; } + output.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + output.flush(); + const bool written = static_cast(output); + output.close(); + if (!written || std::rename(temporary.c_str(), destination.c_str()) != 0) { + std::remove(temporary.c_str()); + showToast("Could not save Switchnote to the SD card"); + return; + } + scanLibrary(); + showToast(verified.metadata.locked ? "Locked Switchnote saved locally (read-only)" : "Switchnote saved locally"); +} + +void App::poll() { + pointerPressed = false; + pointerReleased = false; + touchHoverExpiryPending = false; + clickConsumed = false; + buttonsDown = 0; + buttonsUp = 0; + wheel = 0; + const auto canvasControlAt = [&](int x, int y) { + if (screen != Screen::Canvas || canvasChromeVisible < 0.999f) return false; + if (paintMenu || colorMenu || toolMenu) return true; + const int menuX = 154; + const int menuY = 635 + static_cast(std::lround((1.0f - canvasMenuProgress) * 95.0f)); + return (x >= 20 && x < 164 && y >= 18 && y < 72) || + (x >= 20 && x < 92 && y >= 238 && y < 498) || + (x >= 1200 && x < 1260 && y >= 12 && y < 212) || + (x >= 20 && x < 164 && y >= 635 && y < 697) || + (canvasMenuProgress > 0.0f && x >= menuX && x < menuX + 310 && y >= menuY - 9 && y < menuY + 71) || + (x >= 474 && x < 800 && y >= 635 && y < 700) || + (x >= 904 && x < 1230 && y >= 635 && y < 700); + }; + const auto closeCanvasMenuOnOutsidePress = [&](int x, int y) { + if (screen != Screen::Canvas || !canvasMenuOpen) return; + const int menuX = 154; + const int menuY = 635 + static_cast(std::lround((1.0f - canvasMenuProgress) * 95.0f)); + const bool onToggle = x >= 92 && x < 156 && y >= 635 && y < 697; + const bool onPopup = canvasMenuProgress > 0.0f && x >= menuX && x < menuX + 310 && y >= menuY - 9 && y < menuY + 71; + if (!onToggle && !onPopup) canvasMenuOpen = false; + }; + SDL_Event event; + while (SDL_PollEvent(&event)) { + switch (event.type) { + case SDL_QUIT: running = false; break; + case SDL_CONTROLLERDEVICEADDED: + if (!controller && SDL_IsGameController(event.cdevice.which)) controller = SDL_GameControllerOpen(event.cdevice.which); + break; + case SDL_CONTROLLERDEVICEREMOVED: + if (controller && SDL_JoystickInstanceID(SDL_GameControllerGetJoystick(controller)) == event.cdevice.which) { + SDL_GameControllerClose(controller); + controller = nullptr; + } + break; + case SDL_CONTROLLERBUTTONDOWN: { + const auto bit = controllerBit(event.cbutton.button); + buttonsDown |= bit; + buttonsHeld |= bit; + break; + } + case SDL_CONTROLLERBUTTONUP: { + const auto bit = controllerBit(event.cbutton.button); + buttonsHeld &= ~bit; + buttonsUp |= bit; + break; + } + case SDL_KEYDOWN: { + if (event.key.repeat) break; + if (modal == Modal::TextInput) { + if (event.key.keysym.sym == SDLK_BACKSPACE && !inputText.empty()) inputText.pop_back(); + else if (event.key.keysym.sym == SDLK_RETURN) applyText(); + else if (event.key.keysym.sym == SDLK_ESCAPE) { modal = Modal::None; SDL_StopTextInput(); } + break; + } + std::uint32_t bit = 0; + switch (event.key.keysym.sym) { + case SDLK_RETURN: case SDLK_SPACE: case SDLK_a: bit = BA; break; + case SDLK_ESCAPE: case SDLK_b: bit = BB; break; + case SDLK_x: bit = BX; break; + case SDLK_y: bit = BY; break; + case SDLK_l: bit = BL; break; + case SDLK_r: bit = BR; break; + case SDLK_q: bit = BZL; break; + case SDLK_e: bit = BZR; break; + case SDLK_LEFT: bit = BLEFT; break; + case SDLK_RIGHT: bit = BRIGHT; break; + case SDLK_UP: bit = BUP; break; + case SDLK_DOWN: bit = BDOWN; break; + case SDLK_EQUALS: bit = BPLUS; break; + case SDLK_MINUS: bit = BMINUS; break; + default: break; + } + buttonsDown |= bit; + buttonsHeld |= bit; + break; + } + case SDL_KEYUP: { + std::uint32_t bit = 0; + switch (event.key.keysym.sym) { + case SDLK_RETURN: case SDLK_SPACE: case SDLK_a: bit = BA; break; + case SDLK_ESCAPE: case SDLK_b: bit = BB; break; + case SDLK_x: bit = BX; break; + case SDLK_y: bit = BY; break; + case SDLK_l: bit = BL; break; + case SDLK_r: bit = BR; break; + case SDLK_q: bit = BZL; break; + case SDLK_e: bit = BZR; break; + case SDLK_LEFT: bit = BLEFT; break; + case SDLK_RIGHT: bit = BRIGHT; break; + case SDLK_UP: bit = BUP; break; + case SDLK_DOWN: bit = BDOWN; break; + case SDLK_EQUALS: bit = BPLUS; break; + case SDLK_MINUS: bit = BMINUS; break; + default: break; + } + buttonsHeld &= ~bit; + buttonsUp |= bit; + break; + } + case SDL_TEXTINPUT: + if (modal == Modal::TextInput && inputText.size() < 240) inputText += event.text.text; + break; + case SDL_MOUSEMOTION: + if (event.motion.which != SDL_TOUCH_MOUSEID) { pointerX = event.motion.x; pointerY = event.motion.y; } + break; + case SDL_MOUSEBUTTONDOWN: + if (event.button.which != SDL_TOUCH_MOUSEID && event.button.button == SDL_BUTTON_LEFT) { + pointerX = event.button.x; pointerY = event.button.y; pointerDown = pointerPressed = true; hasLastStroke = false; + closeCanvasMenuOnOutsidePress(pointerX, pointerY); + canvasControlGesture = canvasControlAt(pointerX, pointerY); + const bool inkSurface = (screen == Screen::Canvas && !canvasControlGesture) || + (screen == Screen::CommentCanvas && pointerY >= 72 && pointerY < 620); + if (!inkSurface) playEvent("ui_button_down", "none"); + } + break; + case SDL_MOUSEBUTTONUP: + if (event.button.which != SDL_TOUCH_MOUSEID && event.button.button == SDL_BUTTON_LEFT) { + pointerX = event.button.x; pointerY = event.button.y; pointerDown = false; pointerReleased = true; + const bool inkSurface = (screen == Screen::Canvas && !canvasControlGesture) || + (screen == Screen::CommentCanvas && pointerY >= 72 && pointerY < 620); + if (!inkSurface) playEvent("ui_button_up", "none"); + } + break; + case SDL_FINGERDOWN: + pointerX = static_cast(event.tfinger.x * W); pointerY = static_cast(event.tfinger.y * H); + pointerDown = pointerPressed = true; hasLastStroke = false; + closeCanvasMenuOnOutsidePress(pointerX, pointerY); + canvasControlGesture = canvasControlAt(pointerX, pointerY); + if (!(screen == Screen::Canvas && !canvasControlGesture) && + !(screen == Screen::CommentCanvas && pointerY >= 72 && pointerY < 620)) playEvent("ui_button_down", "none"); + break; + case SDL_FINGERMOTION: + pointerX = static_cast(event.tfinger.x * W); pointerY = static_cast(event.tfinger.y * H); + break; + case SDL_FINGERUP: + pointerX = static_cast(event.tfinger.x * W); pointerY = static_cast(event.tfinger.y * H); + pointerDown = false; pointerReleased = true; + touchHoverExpiryPending = true; + if (!(screen == Screen::Canvas && !canvasControlGesture) && + !(screen == Screen::CommentCanvas && pointerY >= 72 && pointerY < 620)) playEvent("ui_button_up", "none"); + break; + case SDL_MOUSEWHEEL: wheel += event.wheel.y; break; + default: break; + } + } + if (controller) { + const std::uint32_t previousHeld = buttonsHeld; + const bool zl = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERLEFT) > 16000; + const bool zr = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERRIGHT) > 16000; + if (zl && !(buttonsHeld & BZL)) buttonsDown |= BZL; + if (zr && !(buttonsHeld & BZR)) buttonsDown |= BZR; + if (!zl && (previousHeld & BZL)) buttonsUp |= BZL; + if (!zr && (previousHeld & BZR)) buttonsUp |= BZR; + buttonsHeld = (buttonsHeld & ~(BZL | BZR)) | + (zl ? static_cast(BZL) : 0u) | + (zr ? static_cast(BZR) : 0u); + const auto normalizedStick = [](Sint16 value) { + constexpr float deadZone = 0.18f; + const float normalized = value < 0 ? value / 32768.0f : value / 32767.0f; + if (std::abs(normalized) <= deadZone) return 0.0f; + return (normalized - (normalized < 0.0f ? -deadZone : deadZone)) / (1.0f - deadZone); + }; + leftStickX = normalizedStick(SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTX)); + leftStickY = normalizedStick(SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTY)); + } else { + leftStickX = leftStickY = 0.0f; + } + const std::array, 17> inputButtons{{ + {BA, "a"}, {BB, "b"}, {BX, "x"}, {BY, "y"}, {BL, "l"}, {BR, "r"}, + {BZL, "zl"}, {BZR, "zr"}, {BLEFT, "left"}, {BRIGHT, "right"}, + {BUP, "up"}, {BDOWN, "down"}, {BPLUS, "plus"}, {BMINUS, "minus"}, + {BLS, "ls"}, {BRS, "rs"}, {BMISC, "misc"} + }}; + for (const auto& button : inputButtons) { + if (buttonsDown & button.first) playEvent(std::string("button_") + button.second + "_down", "none"); + if (buttonsUp & button.first) playEvent(std::string("button_") + button.second + "_up", "none"); + } +} + +void App::showToast(const std::string& message) { + toast = message; + toastUntil = SDL_GetTicks() + 2500; +} + +void App::loadPreferences() { + std::ifstream in(join(dataRoot, "preferences.ini")); + int storedFps = defaultFps; + int storedOnion = onion ? 1 : 0; + int storedGrid = canvasGrid ? 1 : 0; + int storedCopy = copyToNewPage ? 1 : 0; + int storedStabilisation = penStabilisationEnabled ? 1 : 0; + int storedAfter = penStabilisationAfter ? 1 : 0; + int storedStrength = penStabilisationStrength; + if (in >> storedFps >> storedOnion >> storedGrid) { + defaultFps = std::clamp(storedFps, 1, 30); + defaultSpeed = speedFromFps(defaultFps); + onion = storedOnion != 0; + canvasGrid = storedGrid != 0; + if (in >> storedCopy) { + copyToNewPage = storedCopy != 0; + if (in >> storedStabilisation >> storedAfter >> storedStrength) { + penStabilisationEnabled = storedStabilisation != 0; + penStabilisationAfter = storedAfter != 0; + penStabilisationStrength = std::clamp(storedStrength, 0, 100); + } + } + int storedSpeed = 0; + if (in >> storedSpeed) { + if (storedSpeed >= 1 && storedSpeed <= 8) defaultSpeed = storedSpeed; + } + } + std::ifstream branding(join(dataRoot, "branding.ini"), std::ios::binary); + std::string line; + if (branding && std::getline(branding, line) && !line.empty() && line.size() <= 40) appDisplayName = line; + if (branding && std::getline(branding, line) && !line.empty() && line.size() <= 24) noteSingular = line; + if (branding && std::getline(branding, line) && !line.empty() && line.size() <= 24) notePlural = line; + const std::string savedLogo = join(dataRoot, "branding-logo.png"); + std::ifstream logo(savedLogo, std::ios::binary); + if (logo) brandingLogoPath = savedLogo; + std::ifstream galleryTerms(join(dataRoot, "gallery-terms-accepted"), std::ios::binary); + galleryTermsAccepted = static_cast(galleryTerms); +} + +bool App::startBrandingConfigServer() { +#ifdef __SWITCH__ + if (brandingConfigServer.isRunning()) return true; + if (!gallerySocketsReady) { + const Result sockets = socketInitializeDefault(); + if (R_FAILED(sockets)) { brandingStatus = "Network initialization failed."; return false; } + gallerySocketsReady = true; + } + if (!nifmReady) { + const Result network = nifmInitialize(NifmServiceType_User); + if (R_FAILED(network)) { brandingStatus = "Could not read the Switch network address."; return false; } + nifmReady = true; + } + u32 rawAddress = 0; + if (R_FAILED(nifmGetCurrentIpAddress(&rawAddress))) { brandingStatus = "Connect the Switch to local Wi-Fi first."; return false; } + const std::uint32_t hostAddress = ntohl(rawAddress); + const bool privateAddress = (hostAddress >> 24) == 10 || (hostAddress & 0xfff00000U) == 0xac100000U || + (hostAddress & 0xffff0000U) == 0xc0a80000U || (hostAddress & 0xffff0000U) == 0xa9fe0000U || + (hostAddress & 0xffc00000U) == 0x64400000U; + if (!privateAddress) { brandingStatus = "Connect to a private local network to use the portal."; return false; } + in_addr address{}; address.s_addr = rawAddress; char addressText[INET_ADDRSTRLEN]{}; + if (!::inet_ntop(AF_INET, &address, addressText, sizeof(addressText))) { brandingStatus = "Could not format the Switch address."; return false; } + std::uint32_t randomValue = 0; + do { if (!randomBytes(&randomValue, sizeof(randomValue))) { brandingStatus = "Could not create a secure connection code."; return false; } } + while (randomValue >= 4294000000U); + const unsigned int pin = randomValue % 1000000U; char formattedCode[8]{}; + std::snprintf(formattedCode, sizeof(formattedCode), "%03u-%03u", pin / 1000U, pin % 1000U); + std::string error; + if (!brandingConfigServer.start(addressText, dataRoot, assetsRoot, formattedCode, error, + appDisplayName, noteSingular, notePlural)) { + brandingStatus = error.empty() ? "Could not start the branding portal." : error; return false; + } + brandingCode = formattedCode; brandingUrl = "http://" + std::string(addressText) + ":8767"; + brandingStatus = "Enter the code on your PC."; return true; +#else + brandingStatus = "The branding portal is available in the Switch homebrew app."; return false; +#endif +} + +void App::stopBrandingConfigServer() { + brandingConfigServer.stop(); brandingUrl.clear(); brandingCode.clear(); +} + +void App::closeBrandingMenu() { + stopBrandingConfigServer(); modal = Modal::None; +} + +void App::consumeBrandingConfig() { + if (!brandingConfigServer.takeConfigUpdate()) return; + const std::string configPath = join(dataRoot, "branding.ini"); + std::ifstream in(configPath, std::ios::binary); std::string app, singular, plural; + if (!in || !std::getline(in, app) || !std::getline(in, singular) || !std::getline(in, plural)) { + brandingStatus = "Branding file could not be loaded."; return; + } + if (app.empty() || app.size() > 40 || singular.empty() || singular.size() > 24 || plural.empty() || plural.size() > 24) { + brandingStatus = "Branding values are invalid."; return; + } + const std::string pending = join(dataRoot, "branding-logo.pending"); + std::ifstream logo(pending, std::ios::binary); + if (logo) { + logo.close(); + SDL_Texture* validationTexture = loadBrandingLogo(pending); + if (!validationTexture) { brandingStatus = "The uploaded logo could not be decoded."; showToast(brandingStatus); return; } + SDL_DestroyTexture(validationTexture); + const std::string destination = join(dataRoot, "branding-logo.png"); + // FAT/SD rename replacement is unreliable on Switch. The pending + // upload has already been decoded successfully, so copy it into the + // stable logo path and verify the completed file before accepting it. + std::ifstream uploaded(pending, std::ios::binary | std::ios::ate); + if (!uploaded) { brandingStatus = "The uploaded logo could not be opened."; showToast(brandingStatus); return; } + const std::streamoff logoSize = uploaded.tellg(); + if (logoSize <= 0 || logoSize > 8 * 1024 * 1024) { brandingStatus = "The uploaded logo has an invalid size."; showToast(brandingStatus); return; } + std::vector logoBytes(static_cast(logoSize)); + uploaded.seekg(0, std::ios::beg); + uploaded.read(logoBytes.data(), static_cast(logoBytes.size())); + if (!uploaded) { brandingStatus = "The uploaded logo could not be read."; showToast(brandingStatus); return; } + uploaded.close(); + std::ofstream savedLogo(destination, std::ios::binary | std::ios::trunc); + if (!savedLogo) { brandingStatus = "The title logo could not be opened on the SD card."; showToast(brandingStatus); return; } + savedLogo.write(logoBytes.data(), static_cast(logoBytes.size())); + savedLogo.flush(); + if (!savedLogo) { savedLogo.close(); brandingStatus = "The title logo could not be written to the SD card."; showToast(brandingStatus); return; } + savedLogo.close(); + SDL_Texture* savedValidation = savedLogo ? loadBrandingLogo(destination) : nullptr; + if (savedValidation) SDL_DestroyTexture(savedValidation); + if (!savedLogo || !savedValidation) { + brandingStatus = "The title logo could not be verified after saving."; showToast(brandingStatus); return; + } + std::remove(pending.c_str()); + brandingLogoPath = destination; + } + bool hasLoadingUpload = false; + for (std::size_t i = 0; i < loadingFrogFrames.size(); ++i) { + const std::string pendingFrame = join(dataRoot, "branding-loading" + std::to_string(i) + ".pending"); + std::ifstream pending(pendingFrame, std::ios::binary | std::ios::ate); + if (!pending) continue; + hasLoadingUpload = true; + const std::streamoff length = pending.tellg(); + if (length <= 8 || length > 128 * 1024) { brandingStatus = "A loading frame has an invalid size."; showToast(brandingStatus); return; } + pending.close(); + SDL_Texture* check = loadBrandingLogo(pendingFrame); + if (!check) { brandingStatus = "A loading frame could not be decoded."; showToast(brandingStatus); return; } + int frameW = 0, frameH = 0; + SDL_QueryTexture(check, nullptr, nullptr, &frameW, &frameH); + SDL_DestroyTexture(check); + if (frameW != 64 || frameH != 64) { brandingStatus = "Loading frames must be 64 by 64 pixels."; showToast(brandingStatus); return; } + } + if (hasLoadingUpload) { + for (std::size_t i = 0; i < loadingFrogFrames.size(); ++i) { + const std::string pendingFrame = join(dataRoot, "branding-loading" + std::to_string(i) + ".pending"); + std::ifstream inFrame(pendingFrame, std::ios::binary | std::ios::ate); + if (!inFrame) { brandingStatus = "The loading animation upload is incomplete."; showToast(brandingStatus); return; } + const std::streamoff length = inFrame.tellg(); + std::vector frameBytes(static_cast(length)); + inFrame.seekg(0); inFrame.read(frameBytes.data(), length); + if (!inFrame) { brandingStatus = "Could not read the loading animation."; showToast(brandingStatus); return; } + const std::string destination = join(dataRoot, "branding-loading" + std::to_string(i) + ".png"); + std::ofstream outFrame(destination, std::ios::binary | std::ios::trunc); + if (!outFrame) { brandingStatus = "Could not open a loading frame on the SD card."; showToast(brandingStatus); return; } + outFrame.write(frameBytes.data(), static_cast(frameBytes.size())); + outFrame.close(); + SDL_Texture* replacement = loadBrandingLogo(destination); + if (!outFrame || !replacement) { if (replacement) SDL_DestroyTexture(replacement); brandingStatus = "Could not save the loading animation."; showToast(brandingStatus); return; } + if (loadingFrogFrames[i]) SDL_DestroyTexture(loadingFrogFrames[i]); + loadingFrogFrames[i] = replacement; + std::remove(pendingFrame.c_str()); + } + } + brandingStatus = "Branding saved. Restart the app for changes to apply."; + if (modal == Modal::Branding) showToast(brandingStatus); +} + +void App::exportBrandingConfig() { + branding::Bundle bundle; + bundle.app = appDisplayName; bundle.singular = noteSingular; bundle.plural = notePlural; + const std::string logoPath = brandingLogoPath.empty() + ? join(assetsRoot, "art/switchnotedefault.png") : brandingLogoPath; + std::ifstream logo(logoPath, std::ios::binary | std::ios::ate); + if (!logo) { showToast("Could not read the current title logo"); return; } + const std::streamoff size = logo.tellg(); + if (size <= 0 || size > 8 * 1024 * 1024) { showToast("The current title logo has an invalid size"); return; } + bundle.logo.resize(static_cast(size)); logo.seekg(0); + logo.read(reinterpret_cast(bundle.logo.data()), size); + if (!logo) { showToast("Could not read the current title logo"); return; } + const std::string directory = join(dataRoot, "exports"); + mkdir(directory.c_str(), 0777); + std::string error; + if (!branding::writeConfig(join(directory, "branding.config"), bundle, error)) { showToast(error); return; } + showToast("Branding exported to exports/branding.config"); +} + +void App::importBrandingConfig() { + branding::Bundle bundle; + std::string error; + if (!branding::readConfig(join(dataRoot, "branding.config"), bundle, error)) { showToast(error); return; } + const std::string staging = join(dataRoot, "branding-logo.import"); + { + std::ofstream image(staging, std::ios::binary | std::ios::trunc); + if (!image) { showToast("Could not stage the imported logo"); return; } + image.write(reinterpret_cast(bundle.logo.data()), static_cast(bundle.logo.size())); + image.flush(); + if (!image) { image.close(); std::remove(staging.c_str()); showToast("Could not write the imported logo"); return; } + } + SDL_Texture* validation = loadBrandingLogo(staging); + if (!validation) { std::remove(staging.c_str()); showToast("The imported logo is not a valid image"); return; } + SDL_DestroyTexture(validation); + const std::string configPath = join(dataRoot, "branding.ini"); + std::ofstream config(configPath, std::ios::binary | std::ios::trunc); + if (!config) { std::remove(staging.c_str()); showToast("Could not save imported branding"); return; } + config << bundle.app << '\n' << bundle.singular << '\n' << bundle.plural << '\n'; + config.flush(); + if (!config) { config.close(); std::remove(staging.c_str()); showToast("Could not save imported branding"); return; } + config.close(); + const std::string logoPath = join(dataRoot, "branding-logo.png"); + std::ifstream src(staging, std::ios::binary); + std::ofstream dst(logoPath, std::ios::binary | std::ios::trunc); + dst << src.rdbuf(); dst.flush(); + const bool logoSaved = static_cast(src) && static_cast(dst); + dst.close(); src.close(); std::remove(staging.c_str()); + if (!logoSaved) { showToast("Branding was loaded, but the title logo could not be saved"); return; } + brandingStatus = "Branding imported. Restart the app for changes to apply."; + showToast(brandingStatus); +} + +void App::savePreferences() { + std::ofstream out(join(dataRoot, "preferences.ini"), std::ios::trunc); + if (out) { + out << defaultFps << ' ' << (onion ? 1 : 0) << ' ' << (canvasGrid ? 1 : 0) << ' ' + << (copyToNewPage ? 1 : 0) << ' ' << (penStabilisationEnabled ? 1 : 0) << ' ' + << (penStabilisationAfter ? 1 : 0) << ' ' << penStabilisationStrength << ' ' << defaultSpeed << '\n'; + } +} + +void App::playCue(const std::string& name) { + playEvent("ui_action", name); +} + +void App::playEvent(const std::string& event, const std::string& defaultCue) { + std::string cue = defaultCue; + if (event == "ui_button_down" || (event.rfind("button_", 0) == 0 && endsWith(event, "_down"))) cue = "button_on"; + else if (event == "ui_button_up" || (event.rfind("button_", 0) == 0 && endsWith(event, "_up"))) cue = "button_off"; + else if (event == "frame_previous") cue = "page_backward"; + else if (event == "frame_next") cue = "page_forward"; + else if (event == "frame_first") cue = "page_first"; + else if (event == "frame_last") cue = "page_end"; + else if (event == "frame_copy") cue = "edit_page_copy"; + else if (event == "frame_paste") cue = "edit_page_paste"; + else if (event == "frame_erase") cue = "edit_page_remove"; + else if (event == "tool_color") cue = "popup_select"; + if (cue.empty() || cue == "none") return; + if (!audioReady) return; + auto found = cues.find(cue); + if (found == cues.end() || !found->second) return; + const int channel = cue == "button_on" ? 50 : cue == "button_off" ? 51 : 15; + Mix_HaltChannel(channel); + Mix_PlayChannel(channel, found->second, 0); +} + +SDL_Texture* App::image(const std::string& relative) { + auto found = images.find(relative); + if (found != images.end()) return found->second; + const std::string path = join(assetsRoot, relative); + SDL_Texture* tex = nullptr; + if (endsWith(relative, ".svg")) { + SDL_RWops* source = SDL_RWFromFile(path.c_str(), "rb"); + SDL_Surface* surface = source ? IMG_LoadSVG_RW(source) : nullptr; + if (source) SDL_RWclose(source); + if (surface) { + tex = SDL_CreateTextureFromSurface(renderer, surface); + SDL_FreeSurface(surface); + } + } else tex = IMG_LoadTexture(renderer, path.c_str()); + if (tex && relative.find("art/") == 0) SDL_SetTextureScaleMode(tex, SDL_ScaleModeNearest); + if (!tex) logStartup("asset load failed: " + path + " :: " + IMG_GetError()); + images[relative] = tex; + return tex; +} + +SDL_Texture* App::whiteImage(const std::string& relative) { + auto found = whiteImages.find(relative); + if (found != whiteImages.end()) return found->second; + const std::string path = join(assetsRoot, relative); + SDL_Surface* source = nullptr; + if (endsWith(relative, ".svg")) { + SDL_RWops* stream = SDL_RWFromFile(path.c_str(), "rb"); + source = stream ? IMG_LoadSVG_RW(stream) : nullptr; + if (stream) SDL_RWclose(stream); + } else source = IMG_Load(path.c_str()); + if (!source) { + logStartup("white icon load failed: " + path + " :: " + IMG_GetError()); + whiteImages[relative] = nullptr; + return nullptr; + } + SDL_Surface* rgba = SDL_ConvertSurfaceFormat(source, SDL_PIXELFORMAT_RGBA32, 0); + SDL_FreeSurface(source); + if (!rgba) { + whiteImages[relative] = nullptr; + return nullptr; + } + if (SDL_MUSTLOCK(rgba)) SDL_LockSurface(rgba); + for (int y = 0; y < rgba->h; ++y) { + auto* row = reinterpret_cast(static_cast(rgba->pixels) + y * rgba->pitch); + for (int x = 0; x < rgba->w; ++x) { + Uint8 r, g, b, a; + SDL_GetRGBA(row[x], rgba->format, &r, &g, &b, &a); + row[x] = SDL_MapRGBA(rgba->format, 255, 255, 255, a); + } + } + if (SDL_MUSTLOCK(rgba)) SDL_UnlockSurface(rgba); + SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, rgba); + SDL_FreeSurface(rgba); + if (texture) SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND); + whiteImages[relative] = texture; + return texture; +} + +SDL_Texture* App::loadBrandingLogo(const std::string& path) { + std::ifstream file(path, std::ios::binary | std::ios::ate); + if (!file) return nullptr; + const std::streamoff length = file.tellg(); + if (length < 8 || length > 8 * 1024 * 1024 || length > std::numeric_limits::max()) return nullptr; + std::vector bytes(static_cast(length)); + file.seekg(0, std::ios::beg); + if (!file.read(reinterpret_cast(bytes.data()), length)) return nullptr; + file.close(); + SDL_RWops* stream = SDL_RWFromConstMem(bytes.data(), static_cast(bytes.size())); + SDL_Surface* surface = stream ? IMG_Load_RW(stream, 1) : nullptr; + if (!surface) return nullptr; + if (surface->w <= 0 || surface->h <= 0 || surface->w > 8192 || surface->h > 8192) { + SDL_FreeSurface(surface); + return nullptr; + } + SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface); + SDL_FreeSurface(surface); + if (texture) SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND); + return texture; +} + +void App::drawTexture(SDL_Texture* texture, int x, int y, int w, int h) { + if (!texture || w <= 0 || h <= 0) return; + SDL_Rect dest{x, y, w, h}; + SDL_RenderCopy(renderer, texture, nullptr, &dest); +} + +void App::drawImage(const std::string& relative, int x, int y, int w, int h, bool nearest) { + SDL_Texture* tex = image(relative); + if (tex && nearest) SDL_SetTextureScaleMode(tex, SDL_ScaleModeNearest); + drawTexture(tex, x, y, w, h); +} + +TTF_Font* App::font(int px, bool bold) { + (void)bold; + const auto key = std::make_pair(px, false); + auto found = fonts.find(key); + if (found != fonts.end()) { + return found->second; + } + const std::string regularPath = join(assetsRoot, "fonts/FOT-Seurat.otf"); + TTF_Font* f = TTF_OpenFont(regularPath.c_str(), px); + fonts[key] = f; + return f; +} + +std::string App::brandString(const std::string& label) const { + if (label == appDisplayName) return label; + std::string display = label; + auto replaceTerm = [&](const std::string& oldTerm, const std::string& newTerm) { + std::size_t at = 0; + while (!oldTerm.empty() && (at = display.find(oldTerm, at)) != std::string::npos) { + const bool left = at == 0 || !std::isalnum(static_cast(display[at - 1])); + const std::size_t end = at + oldTerm.size(); + const bool right = end == display.size() || !std::isalnum(static_cast(display[end])); + if (left && right) { display.replace(at, oldTerm.size(), newTerm); at += newTerm.size(); } + else at = end; + } + }; + replaceTerm("Switchnote Studio", appDisplayName); + replaceTerm("switchnote studio", appDisplayName); + replaceTerm("Switchnotes", notePlural); + replaceTerm("Switchnote", noteSingular); + replaceTerm("switchnotes", notePlural); + replaceTerm("switchnote", noteSingular); + return display; +} + +void App::text(const std::string& label, int x, int y, int px, SDL_Color color, bool bold) { + rawText(brandString(label), x, y, px, color, bold); +} + +void App::rawText(const std::string& display, int x, int y, int px, SDL_Color color, bool bold) { + if (display.empty()) return; + TTF_Font* f = font(px, bold); + if (!f) return; + const std::string key = std::to_string(px) + (bold ? "B" : "R") + + std::to_string(color.r) + ":" + std::to_string(color.g) + ":" + std::to_string(color.b) + ":" + display; + SDL_Texture* tex = nullptr; + auto found = textCache.find(key); + if (found != textCache.end()) tex = found->second; + else { + SDL_Surface* surf = TTF_RenderUTF8_Blended(f, display.c_str(), color); + if (!surf) return; + tex = SDL_CreateTextureFromSurface(renderer, surf); + SDL_FreeSurface(surf); + if (!tex) return; + textCache[key] = tex; + if (textCache.size() > 400) { + for (auto& old : textCache) if (old.second != tex) SDL_DestroyTexture(old.second); + textCache.clear(); + textCache[key] = tex; + } + } + int w = 0, h = 0; + SDL_QueryTexture(tex, nullptr, nullptr, &w, &h); + drawTexture(tex, x - 3, y + 1, w, h); +} + +void App::centered(const std::string& label, int x, int y, int w, int h, int px, SDL_Color color, bool bold) { + const std::string display = brandString(label); + TTF_Font* f = font(px, bold); + if (!f) return; + int tw = 0, th = 0; + TTF_SizeUTF8(f, display.c_str(), &tw, &th); + rawText(display, x + (w - tw) / 2, y + (h - th) / 2, px, color, bold); +} + +void App::fill(int x, int y, int w, int h, SDL_Color c) { + SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); + SDL_Rect rect{x, y, w, h}; + SDL_RenderFillRect(renderer, &rect); +} + +void App::drawPlayTriangle(int x, int y, int size, SDL_Color color) { + if (!renderer || size <= 1) return; + SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); + for (int row = 0; row < size; ++row) { + const int extent = std::min(row, size - 1 - row); + SDL_RenderDrawLine(renderer, x, y + row, x + extent * 2, y + row); + } +} + +void App::drawLockIcon(int x, int y) { + rounded(x, y, 28, 28, 14, SDL_Color{20, 22, 28, 220}); + SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255); + SDL_RenderDrawLine(renderer, x + 8, y + 13, x + 8, y + 10); + SDL_RenderDrawLine(renderer, x + 8, y + 10, x + 10, y + 7); + SDL_RenderDrawLine(renderer, x + 10, y + 7, x + 18, y + 7); + SDL_RenderDrawLine(renderer, x + 18, y + 7, x + 20, y + 10); + SDL_RenderDrawLine(renderer, x + 20, y + 10, x + 20, y + 13); + rounded(x + 6, y + 12, 16, 12, 3, WHITE); + rounded(x + 13, y + 16, 2, 5, 1, INK); +} + +void App::rounded(int x, int y, int w, int h, int radius, SDL_Color c) { + if (w <= 0 || h <= 0) return; + radius = std::max(0, std::min(radius, std::min(w, h) / 2)); + SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); + for (int row = 0; row < h; ++row) { + int inset = 0; + if (row < radius) { + const float dy = static_cast(radius - row - 1); + inset = radius - static_cast(std::sqrt(static_cast(radius * radius) - dy * dy)); + } else if (row >= h - radius) { + const float dy = static_cast(row - (h - radius)); + inset = radius - static_cast(std::sqrt(static_cast(radius * radius) - dy * dy)); + } + SDL_RenderDrawLine(renderer, x + inset, y + row, x + w - inset - 1, y + row); + } +} + +void App::roundedOutline(int x, int y, int w, int h, int radius, int thickness, SDL_Color c) { + if (w <= 0 || h <= 0 || thickness <= 0) return; + radius = std::clamp(radius, 0, std::min(w, h) / 2); + thickness = std::min(thickness, std::min(w, h) / 2); + const int innerW = w - thickness * 2; + const int innerH = h - thickness * 2; + const int innerRadius = std::max(0, radius - thickness); + SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a); + auto insetFor = [](int row, int height, int r) { + if (r <= 0) return 0; + if (row < r) { + const float dy = static_cast(r - row - 1); + return r - static_cast(std::sqrt(static_cast(r * r) - dy * dy)); + } + if (row >= height - r) { + const float dy = static_cast(row - (height - r)); + return r - static_cast(std::sqrt(static_cast(r * r) - dy * dy)); + } + return 0; + }; + for (int row = 0; row < h; ++row) { + const int outerInset = insetFor(row, h, radius); + const int outerLeft = x + outerInset; + const int outerRight = x + w - outerInset - 1; + const int innerRow = row - thickness; + if (innerRow < 0 || innerRow >= innerH) { + SDL_RenderDrawLine(renderer, outerLeft, y + row, outerRight, y + row); + continue; + } + const int innerInset = insetFor(innerRow, innerH, innerRadius); + const int innerLeft = x + thickness + innerInset; + const int innerRight = x + thickness + innerW - innerInset - 1; + if (outerLeft < innerLeft) SDL_RenderDrawLine(renderer, outerLeft, y + row, innerLeft - 1, y + row); + if (innerRight < outerRight) SDL_RenderDrawLine(renderer, innerRight + 1, y + row, outerRight, y + row); + } +} + +bool App::hit(int x, int y, int w, int h) { + return !updateInProgress && !modalClosing && !screen.transitioning() && !(screen == Screen::Canvas && canvasChromeVisible < 0.999f) && + (modal == Modal::None || renderingModal) && pointerReleased && !clickConsumed && + pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; +} + +int App::hoverLift(int x, int y, bool hovered) { + const auto key = (static_cast(static_cast(x)) << 32) | + static_cast(y); + float& amount = hoverMotion[key]; + if (touchHoverExpiryPending && !pointerDown) { + amount = 0.0f; + return 0; + } + amount += ((hovered ? 1.0f : 0.0f) - amount) * 0.62f; + return static_cast(std::lround(amount * 3.0f)); +} + +bool App::pill(const std::string& label, int x, int y, int w, int h, bool green, int px, bool enabled, const std::string& cue) { + const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; + const bool pressed = enabled && pointerDown && hovered; + const int lift = enabled ? hoverLift(x, y, hovered) : 0; + const int press = pressed ? 4 : 0; + const SDL_Color base = !enabled ? SDL_Color{230, 232, 239, 255} : (green ? PALE_GREEN : CREAM); + const SDL_Color fg = !enabled ? SDL_Color{175, 181, 193, 255} : (green ? GREEN : ORANGE); + rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast(pressed ? 30 : 45)}); + rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE); + rounded(x, y - lift + press, w, h, h / 2, base); + centered(label, x, y - lift + press, w, h, px, fg, false); + if (enabled && hit(x, y, w, h)) { + clickConsumed = true; + if (!cue.empty()) { + if (cue == "folder_open") playEvent("folder_open", cue); + else if (cue == "tool_color") playEvent("tool_color", "draw_tool_btn_on"); + else playEvent("ui_action", cue); + } + return true; + } + return false; +} + +bool App::actionPill(const std::string& icon, const std::string& label, int x, int y, int w, int h, bool green, bool enabled, const std::string& cue) { + const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; + const bool pressed = enabled && pointerDown && hovered; + const int lift = enabled ? hoverLift(x, y, hovered) : 0; + const int press = pressed ? 4 : 0; + const SDL_Color base = !enabled ? SDL_Color{230, 232, 239, 255} : (green ? PALE_GREEN : CREAM); + const SDL_Color fg = !enabled ? SDL_Color{175, 181, 193, 255} : (green ? GREEN : ORANGE); + rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast(pressed ? 30 : 45)}); + rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE); + rounded(x, y - lift + press, w, h, h / 2, base); + const int iconSize = std::min(32, h - 10); + SDL_Texture* tex = image(icon); + if (tex) { + int sourceW = 0, sourceH = 0; + SDL_QueryTexture(tex, nullptr, nullptr, &sourceW, &sourceH); + const float scale = sourceW > 0 && sourceH > 0 ? std::min(iconSize / static_cast(sourceW), iconSize / static_cast(sourceH)) : 1.0f; + const int iconW = std::max(1, static_cast(sourceW * scale)); + const int iconH = std::max(1, static_cast(sourceH * scale)); + if (!enabled) SDL_SetTextureAlphaMod(tex, 110); + drawTexture(tex, x + 13 + (iconSize - iconW) / 2, y + (h - iconH) / 2 - lift + press, iconW, iconH); + if (!enabled) SDL_SetTextureAlphaMod(tex, 255); + } + const int textLeft = x + (tex ? 48 : 10); + centered(label, textLeft, y - lift + press, x + w - textLeft, h, 19, fg, false); + if (enabled && hit(x, y, w, h)) { + clickConsumed = true; + if (!cue.empty()) playEvent("ui_action", cue); + return true; + } + return false; +} + +bool App::dangerPill(const std::string& label, int x, int y, int w, int h, int px, bool enabled) { + const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; + const bool pressed = enabled && pointerDown && hovered; + const int lift = enabled ? hoverLift(x, y, hovered) : 0; + const int press = pressed ? 4 : 0; + const SDL_Color base = enabled ? SDL_Color{218, 47, 57, 255} : SDL_Color{230, 232, 239, 255}; + const SDL_Color fg = enabled ? WHITE : SDL_Color{175, 181, 193, 255}; + rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast(pressed ? 30 : 45)}); + rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE); + rounded(x, y - lift + press, w, h, h / 2, base); + centered(label, x, y - lift + press, w, h, px, fg, false); + if (enabled && hit(x, y, w, h)) { + clickConsumed = true; + playEvent("ui_action", "button_on"); + return true; + } + return false; +} + +bool App::dangerActionPill(const std::string& icon, const std::string& label, int x, int y, int w, int h, int px, bool enabled) { + const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; + const bool pressed = enabled && pointerDown && hovered; + const int lift = enabled ? hoverLift(x, y, hovered) : 0; + const int press = pressed ? 4 : 0; + const SDL_Color base = enabled ? SDL_Color{218, 47, 57, 255} : SDL_Color{230, 232, 239, 255}; + const SDL_Color fg = enabled ? WHITE : SDL_Color{175, 181, 193, 255}; + rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast(pressed ? 30 : 45)}); + rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE); + rounded(x, y - lift + press, w, h, h / 2, base); + SDL_Texture* tex = image(icon); + if (tex) { + int sourceW = 0, sourceH = 0; + SDL_QueryTexture(tex, nullptr, nullptr, &sourceW, &sourceH); + const int iconSize = std::min(32, h - 10); + const float scale = sourceW > 0 && sourceH > 0 ? std::min(iconSize / static_cast(sourceW), iconSize / static_cast(sourceH)) : 1.0f; + const int iconW = std::max(1, static_cast(sourceW * scale)); + const int iconH = std::max(1, static_cast(sourceH * scale)); + if (!enabled) SDL_SetTextureAlphaMod(tex, 110); + drawTexture(tex, x + 13 + (iconSize - iconW) / 2, y + (h - iconH) / 2 - lift + press, iconW, iconH); + if (!enabled) SDL_SetTextureAlphaMod(tex, 255); + } + const int textLeft = x + (tex ? 48 : 10); + centered(label, textLeft, y - lift + press, x + w - textLeft, h, px, fg, false); + if (enabled && hit(x, y, w, h)) { + clickConsumed = true; + playEvent("ui_action", "button_on"); + return true; + } + return false; +} + +bool App::imagePill(const std::string& icon, int x, int y, int w, int h, bool green, bool enabled, const std::string& cue) { + const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; + const bool pressed = enabled && pointerDown && hovered; + const int lift = enabled ? hoverLift(x, y, hovered) : 0; + const int press = pressed ? 4 : 0; + const SDL_Color base = !enabled ? SDL_Color{230, 232, 239, 255} : (green ? PALE_GREEN : CREAM); + rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast(pressed ? 30 : 45)}); + rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE); + rounded(x, y - lift + press, w, h, h / 2, base); + SDL_Texture* tex = image(icon); + if (tex) { + int sourceW = 0, sourceH = 0; + SDL_QueryTexture(tex, nullptr, nullptr, &sourceW, &sourceH); + const float scale = sourceW > 0 && sourceH > 0 + ? std::min((w - 18) / static_cast(sourceW), (h - 12) / static_cast(sourceH)) : 1.0f; + const int iconW = std::max(1, static_cast(sourceW * scale)); + const int iconH = std::max(1, static_cast(sourceH * scale)); + if (!enabled) SDL_SetTextureAlphaMod(tex, 110); + drawTexture(tex, x + (w - iconW) / 2, y + (h - iconH) / 2 - lift + press, iconW, iconH); + if (!enabled) SDL_SetTextureAlphaMod(tex, 255); + } + if (enabled && hit(x, y, w, h)) { + clickConsumed = true; + if (!cue.empty()) playEvent("ui_action", cue); + return true; + } + return false; +} + +bool App::imageButton(const std::string& path, int x, int y, int w, int h, const std::string& cue) { + const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; + const bool pressed = pointerDown && hovered; + drawImage(path, x, y - hoverLift(x, y, hovered) + (pressed ? 4 : 0), w, h); + if (hit(x, y, w, h)) { + clickConsumed = true; + if (!cue.empty()) playEvent("ui_action", cue); + return true; + } + return false; +} + +void App::background() { + if (screen == Screen::Canvas) fill(0, 0, W, H, WHITE); + else drawImage("ui/GridBackground.png", 0, 0, W, H); +} + +void App::renderToast() { + if (toast.empty() || SDL_GetTicks() > toastUntil) return; + TTF_Font* body = font(19, false); + if (!body) return; + constexpr int maxTextWidth = W - 120; + auto measure = [body](const std::string& value) { + int width = 0, height = 0; + TTF_SizeUTF8(body, value.c_str(), &width, &height); + return width; + }; + std::vector lines; + std::istringstream words(toast); + std::string word, line; + while (words >> word) { + const std::string candidate = line.empty() ? word : line + " " + word; + if (!line.empty() && measure(candidate) > maxTextWidth) { + lines.push_back(line); + line = word; + } else line = candidate; + } + if (!line.empty()) lines.push_back(line); + if (lines.empty()) return; + int textWidth = 0; + for (const auto& part : lines) textWidth = std::max(textWidth, measure(part)); + const int lineHeight = TTF_FontHeight(body) + 2; + const int width = std::min(W - 48, textWidth + 48); + const int height = static_cast(lines.size()) * lineHeight + 22; + const int x = (W - width) / 2; + const int y = 82; + rounded(x, y, width, height, std::min(23, height / 2), SDL_Color{20, 24, 30, 225}); + for (std::size_t i = 0; i < lines.size(); ++i) { + const int lineWidth = measure(lines[i]); + rawText(lines[i], (W - lineWidth) / 2, y + 10 + static_cast(i) * lineHeight, 19, WHITE, false); + } +} + +void App::run() { + logStartup("startup: entering main loop"); + const std::uint32_t start = SDL_GetTicks(); + while (running +#ifdef __SWITCH__ + && appletMainLoop() +#endif + ) { + poll(); + pulse = static_cast(SDL_GetTicks() - start) / 1000.0f; + update(); + render(); + if (updateInstallPending && static_cast(SDL_GetTicks() - updateInstallReadyAt) >= 0) + completeUpdateInstall(); + } + logStartup("startup: main loop ended"); +} + +void App::startUpdateCheck(bool manual) { + if (updateInProgress || updateThread) return; + if (manual && !internetConnected()) { + showToast("You're not connected to the internet."); + return; + } + updateTask = UpdateTask::Check; + updateManualCheck = manual; + updateAvailable = false; + updateNoChange = false; + updateServerVersion.clear(); + updateDownloadUrl.clear(); + updateSha256.clear(); + updateError.clear(); + updateHttpStatus = 0; + updateWorkerDone.store(false, std::memory_order_relaxed); + updateInProgress = true; + beginLoading(); + updateStartedAt = SDL_GetTicks(); + updateThread = SDL_CreateThread(updateThreadEntry, "switchnote-update", this); + if (!updateThread) { + updateInProgress = false; + endLoading(); + loadingCompletionVisible = false; + updateError = "Could not start the update check."; + if (manual) showToast(updateError); + } +} + +void App::startUpdateDownload() { + if (updateInProgress || updateThread || updateDownloadUrl.empty()) return; + updateTask = UpdateTask::Download; + updateManualCheck = false; + updateError.clear(); + updateDownloadedPath.clear(); + updateHttpStatus = 0; + updateWorkerDone.store(false, std::memory_order_relaxed); + updateInProgress = true; + modal = Modal::None; + beginLoading(); + updateStartedAt = SDL_GetTicks(); + updateThread = SDL_CreateThread(updateThreadEntry, "switchnote-download", this); + if (!updateThread) { + updateInProgress = false; + endLoading(); + loadingCompletionVisible = false; + showToast("Could not start the update download."); + } +} + +bool App::requestUpdateHandoff() { +#ifdef __SWITCH__ + const std::string installTarget = join(dataRoot, "switchnote.nro"); + if (!envHasNextLoad()) { + updateError = "This launch method cannot open the downloaded update automatically."; + return false; + } + if (!sameSdPath(launchNroPath, installTarget)) { + updateError = "Auto-install is only available when launched from switch/Switchnote/switchnote.nro."; + logStartup("update: launch path did not match install target; loader path=" + + (launchNroPath.empty() ? std::string("") : launchNroPath) + "; expected=" + installTarget); + return false; + } + if (!validNroFile(updateDownloadedPath)) { + updateError = "The downloaded update file could not be verified."; + return false; + } + const std::string nextArguments = updateDownloadedPath + " --switchnote-update-handoff"; + const Result result = envSetNextLoad(updateDownloadedPath.c_str(), nextArguments.c_str()); + if (R_FAILED(result)) { + updateError = "Could not start the downloaded update."; + logStartup("update: envSetNextLoad failed: " + std::to_string(static_cast(result))); + return false; + } + logStartup("update: chained to downloaded NRO " + updateDownloadedPath); + running = false; + return true; +#else + return false; +#endif +} + +void App::completeUpdateInstall() { + updateInstallPending = false; +#ifdef __SWITCH__ + const std::string installTarget = join(dataRoot, "switchnote.nro"); + const std::string backupPath = installTarget + ".oldbk"; + auto exists = [](const std::string& path) { + struct stat st{}; + return stat(path.c_str(), &st) == 0; + }; + auto fail = [&](const std::string& message) { + updateError = message; + logStartup("update: " + message); + showToast(message); + }; + + if (!sameSdPath(updateInstallSourcePath, launchNroPath) || + !validNroFile(updateInstallSourcePath)) { + fail("The running update file could not be verified; the installed app was left unchanged."); + return; + } + + if (exists(backupPath)) { + if (validNroFile(installTarget)) { + if (std::remove(backupPath.c_str()) != 0) { + fail("Could not clear the previous update backup; the installed app was left unchanged."); + return; + } + } else { + if (exists(installTarget)) std::remove(installTarget.c_str()); + if (!validNroFile(backupPath) || std::rename(backupPath.c_str(), installTarget.c_str()) != 0) { + fail("Could not restore the previous app backup; the update was not installed."); + return; + } + } + } + + if (!validNroFile(installTarget)) { + fail("The installed Switchnote NRO was not found; the update was left in the updates folder."); + return; + } + if (std::rename(installTarget.c_str(), backupPath.c_str()) != 0) { + fail("Could not back up the current Switchnote NRO; the update was left in the updates folder."); + return; + } + if (std::rename(updateInstallSourcePath.c_str(), installTarget.c_str()) != 0) { + const bool restored = std::rename(backupPath.c_str(), installTarget.c_str()) == 0; + fail(restored ? "Could not install the update; the previous app was restored." : + "Could not install the update or restore the backup. Keep the .oldbk file for recovery."); + return; + } + if (!validNroFile(installTarget)) { + std::remove(installTarget.c_str()); + const bool restored = std::rename(backupPath.c_str(), installTarget.c_str()) == 0; + fail(restored ? "The new NRO failed verification; the previous app was restored." : + "The new NRO failed verification and rollback failed. Keep the .oldbk file for recovery."); + return; + } + if (std::remove(backupPath.c_str()) == 0) { + logStartup("update: installed new NRO and removed backup"); + showToast("Update installed successfully."); + } else { + logStartup("update: installed new NRO; backup cleanup failed"); + showToast("Update installed. The old backup could not be removed."); + } +#endif +} + +int App::updateThreadEntry(void* context) { + App* app = static_cast(context); + app->runUpdateTask(); + app->updateWorkerDone.store(true, std::memory_order_release); + return 0; +} + +void App::runUpdateTask() { + std::vector response; + std::string responseHeaders; + std::string error; + if (updateTask == UpdateTask::Check) { + const bool ok = galleryRequest("GET", "/api/update", {}, "application/json", + response, updateHttpStatus, error, &responseHeaders); + if (!ok || updateHttpStatus == 503) { + updateError = updateHttpStatus == 503 ? "Update service is temporarily unavailable." : error; + return; + } + const std::string json(response.begin(), response.end()); + updateServerVersion = jsonStringField(json, "version"); + updateDownloadUrl = jsonStringField(json, "download_url"); + updateSha256 = jsonStringField(json, "sha256"); + if (!validSha256Hex(updateSha256)) updateSha256.clear(); + std::vector returnedParts, installedParts; + if (updateServerVersion.empty() || updateDownloadUrl.empty() || + !versionParts(updateServerVersion, returnedParts) || + !versionParts(kInstalledAppVersion, installedParts)) { + updateError = "The update service returned invalid version information."; + return; + } + updateAvailable = compareVersions(updateServerVersion, kInstalledAppVersion) > 0; + updateNoChange = !updateAvailable; + if (updateAvailable) { + std::string trustedPath; + if (!trustedUpdatePath(updateDownloadUrl, trustedPath)) { + updateAvailable = false; + updateNoChange = false; + updateError = "The update service returned an untrusted download address."; + } + } + return; + } + + if (updateTask != UpdateTask::Download) return; + std::string downloadPath; + if (!trustedUpdatePath(updateDownloadUrl, downloadPath)) { + updateError = "The update service returned an untrusted download address."; + return; + } + bool downloaded = false; + for (int attempt = 0; attempt < 3; ++attempt) { + response.clear(); + responseHeaders.clear(); + error.clear(); + const bool ok = galleryRequest("GET", downloadPath, {}, "application/octet-stream", + response, updateHttpStatus, error, &responseHeaders); + if (ok) { downloaded = true; break; } + if (updateHttpStatus == 503) { + updateError = "The update download is temporarily unavailable. Try again later."; + return; + } + if (updateHttpStatus != 429) { + updateError = error.empty() ? "The update download failed." : error; + return; + } + const int waitSeconds = retryAfterSeconds(responseHeaders); + if (waitSeconds < 0 || attempt == 2) { + updateError = "The download limit was reached. Please try again later."; + return; + } + for (int second = 0; second < waitSeconds; ++second) SDL_Delay(1000); + } + if (!downloaded) { updateError = "The update download failed."; return; } + if (response.size() < 20 || response.size() > 72U * 1024U * 1024U || + std::memcmp(response.data() + 16, "NRO0", 4) != 0) { + updateError = "The downloaded file is not a valid Switchnote NRO."; + return; + } + std::string versionComponent = updateServerVersion; + if (versionComponent.rfind("Ver ", 0) == 0) versionComponent.erase(0, 4); + else if (!versionComponent.empty() && (versionComponent[0] == 'v' || versionComponent[0] == 'V')) versionComponent.erase(0, 1); + const std::string directory = join(dataRoot, "updates"); + ensureDir(directory); + std::string destination = join(directory, "Switchnote-" + versionComponent + ".nro"); + struct stat existing{}; + for (unsigned suffix = 1; stat(destination.c_str(), &existing) == 0 && suffix < 1000; ++suffix) + destination = join(directory, "Switchnote-" + versionComponent + "-" + std::to_string(suffix) + ".nro"); + const std::string temporary = destination + ".part"; + std::remove(temporary.c_str()); + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + if (!output) { updateError = "Could not create the update file on the SD card."; return; } + output.write(reinterpret_cast(response.data()), static_cast(response.size())); + output.flush(); + const bool writeOk = static_cast(output); + output.close(); + if (!writeOk || std::rename(temporary.c_str(), destination.c_str()) != 0) { + std::remove(temporary.c_str()); + updateError = "Could not save the update to the SD card."; + return; + } + updateDownloadedPath = destination; +} + +void App::consumeUpdateTask() { + if (!updateInProgress || !updateWorkerDone.load(std::memory_order_acquire) || + SDL_GetTicks() - updateStartedAt < 450) return; + if (updateThread) { SDL_WaitThread(updateThread, nullptr); updateThread = nullptr; } + const UpdateTask finishedTask = updateTask; + const bool wasManual = updateManualCheck; + updateTask = UpdateTask::None; + updateInProgress = false; + endLoading(); + loadingCompletionVisible = false; + if (finishedTask == UpdateTask::Check) { + if (updateAvailable) { + if (wasManual) { modal = Modal::UpdateAvailable; modalTransitionStart = SDL_GetTicks(); } + else updateOfferPending = true; + } + else if (wasManual && updateNoChange) showToast("This version is up to date."); + else if (wasManual) showToast(updateError.empty() ? "Couldn't check for updates. Try again later." : updateError); + } else if (finishedTask == UpdateTask::Download) { + if (!updateDownloadedPath.empty()) { + if (!requestUpdateHandoff()) + showToast(updateError.empty() ? "Update saved to " + updateDownloadedPath + ". Launch that NRO manually." : updateError); + } + else showToast(updateError.empty() ? "The update download failed." : updateError); + } +} + +void App::update() { + const std::uint32_t now = SDL_GetTicks(); + consumeUpdateTask(); + updateFillWave(now); + consumeServerStart(); + consumePhotoProbe(); + consumeGalleryLoad(); + if (screen != Screen::Canvas) canvasMenuOpen = false; + if (screen.phase == ScreenState::Phase::ToWhite && lastScreenCueStart != screen.phaseStart) { + lastScreenCueStart = screen.phaseStart; + playEvent("screen_swap", "screen_swap"); + } + screen.advance(now); + if (updateOfferPending && screen.active == Screen::Title && screen.phase == ScreenState::Phase::Idle) { + updateOfferPending = false; + modal = Modal::UpdateAvailable; + modalTransitionStart = now; + } + if (modalClosing) { + if (now - modalTransitionStart >= 150) { + modalClosing = false; + modalVisual = Modal::None; + } else return; + } + if (!serverStartRunning) consumeAudioUpload(); + if (!serverStartRunning) consumeBrandingConfig(); + advancePhotoImport(); + if (photoImporting) return; + updatePlayback(now); + if ((screen != Screen::Canvas && screen != Screen::Sequence) || modal != Modal::None || + !(buttonsHeld & BRIGHT) || (screen == Screen::Canvas && (buttonsHeld & BY))) { + rightDpadRepeating = false; + } else if (rightDpadRepeating) { + const std::uint32_t interval = std::max(1, static_cast(std::lround(1000.0 / sppm::frameRate(doc.metadata)))); + while (static_cast(now - rightDpadNextRepeatAt) >= 0) { + if (frame + 1 < doc.frames.size()) setFrame(frame + 1); + else insertFrame(false); + rightDpadNextRepeatAt += interval; + } + } + if (modal == Modal::Photo) updatePhotoCapture(); + if (screen.transitioning()) return; + if (dirty && SDL_GetTicks() - dirtyTick > 1500) saveDocument(); + auto canvasInputAllowed = [&]() { + if (pointerX < 0 || pointerX >= W || pointerY < 0 || pointerY >= H) return false; + if (canvasControlGesture || paintMenu || colorMenu || toolMenu) return false; + const float scale = 2.0f * zoom; + const int canvasW = static_cast(sppm::kWidth * scale); + const int canvasH = static_cast(sppm::kHeight * scale); + const int canvasX = (W - canvasW) / 2 + panX; + const int canvasY = (H - canvasH) / 2 + panY; + if (pointerX < canvasX || pointerX >= canvasX + canvasW || + pointerY < canvasY || pointerY >= canvasY + canvasH) return false; + return true; + }; + auto canvasPixelAt = [&](int sx, int sy, int& px, int& py) { + const float scale = 2.0f * zoom; + const int canvasW = static_cast(sppm::kWidth * scale); + const int canvasH = static_cast(sppm::kHeight * scale); + const int canvasX = (W - canvasW) / 2 + panX; + const int canvasY = (H - canvasH) / 2 + panY; + if (sx < canvasX || sx >= canvasX + canvasW || sy < canvasY || sy >= canvasY + canvasH) return false; + px = static_cast((sx - canvasX) / (canvasW / static_cast(sppm::kWidth))); + py = static_cast((sy - canvasY) / (canvasH / static_cast(sppm::kHeight))); + return px >= 0 && px < sppm::kWidth && py >= 0 && py < sppm::kHeight; + }; + if (screen == Screen::Canvas && docLoaded && zoom > 1.0f && !drawing && modal == Modal::None) { + if (!lastPanTick) lastPanTick = now; + const float elapsed = std::min(0.050f, (now - lastPanTick) / 1000.0f); + const int maxPanX = std::max(0, (static_cast(sppm::kWidth * 2.0f * zoom) - W) / 2); + const int maxPanY = std::max(0, (static_cast(sppm::kHeight * 2.0f * zoom) - H) / 2); + constexpr float panPixelsPerSecond = 720.0f; + panX = std::clamp(panX + static_cast(std::lround(leftStickX * panPixelsPerSecond * elapsed)), -maxPanX, maxPanX); + panY = std::clamp(panY + static_cast(std::lround(leftStickY * panPixelsPerSecond * elapsed)), -maxPanY, maxPanY); + } + lastPanTick = now; + if (screen == Screen::Canvas && docLoaded && pointerPressed && modal == Modal::None && canvasInputAllowed()) { + int px = 0, py = 0; + if (canvasPixelAt(pointerX, pointerY, px, py)) { + afterStrokePoints.clear(); + realtimeStrokeInitialized = false; + canvasTapPending = true; + canvasFillCommitted = false; + canvasPressScreenX = pointerX; canvasPressScreenY = pointerY; + canvasPressPixelX = px; canvasPressPixelY = py; + canvasPressStartedAt = now; + drawing = true; // lets the chrome slide away immediately, before ink is committed + hasLastStroke = false; + } + } + if (screen == Screen::Canvas && docLoaded && canvasTapPending && pointerDown && modal == Modal::None) { + const int touchDx = pointerX - canvasPressScreenX; + const int touchDy = pointerY - canvasPressScreenY; + if (touchDx * touchDx + touchDy * touchDy > 25) { + // Five display pixels of touch jitter do not cancel a stationary fill. + canvasTapPending = false; + pushUndo(); + hasLastStroke = false; + strokeAt(canvasPressScreenX, canvasPressScreenY); + strokeAt(pointerX, pointerY); + } else if (now - canvasPressStartedAt >= 450) { + canvasTapPending = false; + canvasFillCommitted = true; + beginFillWave(canvasPressPixelX, canvasPressPixelY); + } + } else if (screen == Screen::Canvas && docLoaded && pointerDown && modal == Modal::None && !canvasTapPending) { + if (canvasInputAllowed()) { + if (!drawing) { pushUndo(); drawing = true; hasLastStroke = false; } + strokeAt(pointerX, pointerY); + } else hasLastStroke = false; + } + if (screen == Screen::Canvas && docLoaded && pointerReleased && modal == Modal::None) { + if (canvasTapPending) { + // A short stationary tap remains a normal single-point stroke. + pushUndo(); + hasLastStroke = false; + strokeAt(canvasPressScreenX, canvasPressScreenY); + } else if (!canvasFillCommitted && canvasInputAllowed()) { + if (!drawing) { pushUndo(); drawing = true; hasLastStroke = false; } + strokeAt(pointerX, pointerY); + } + finishAfterStroke(); + canvasTapPending = false; + canvasFillCommitted = false; + drawing = false; + hasLastStroke = false; + canvasControlGesture = false; + } + if (screen == Screen::CommentCanvas && pointerDown && modal == Modal::None) { + if (pointerY >= 72 && pointerY < 620) { + if (!drawing) { drawing = true; hasLastStroke = false; } + strokeAt(pointerX, pointerY, true); + } + } + if (screen == Screen::CommentCanvas && pointerReleased && modal == Modal::None) { + const bool onButtons = pointerY < 72 || pointerY >= 620; + if (!onButtons) strokeAt(pointerX, pointerY, true); + drawing = false; + hasLastStroke = false; + } + if (lastCanvasMenuTick == 0) lastCanvasMenuTick = now; + if (screen == Screen::GalleryComments) { + if (pointerPressed && pointerX >= 197 && pointerX < 1060 && pointerY >= 130 && pointerY < 620) { + galleryCommentTouchScroll = true; + galleryCommentTouchStartY = pointerY; + galleryCommentTouchStartOffset = galleryCommentScroll; + } + if (galleryCommentTouchScroll && pointerDown) { + galleryCommentScroll = std::max(0, galleryCommentTouchStartOffset + galleryCommentTouchStartY - pointerY); + } + if (pointerReleased) galleryCommentTouchScroll = false; + const float elapsed = lastGalleryCommentScrollTick == 0 ? 0.0f : std::min(0.05f, (now - lastGalleryCommentScrollTick) / 1000.0f); + if (!galleryCommentTouchScroll && std::abs(leftStickY) > 0.18f) { + galleryCommentScroll = std::max(0, galleryCommentScroll + static_cast(std::lround(leftStickY * 700.0f * elapsed))); + } + lastGalleryCommentScrollTick = now; + } else { + galleryCommentTouchScroll = false; + lastGalleryCommentScrollTick = now; + } + constexpr float menuAnimationMs = 125.0f / 1.75f; + const float menuStep = std::min(0.05f, (now - lastCanvasMenuTick) / 1000.0f) / (menuAnimationMs / 1000.0f); + lastCanvasMenuTick = now; + canvasMenuProgress = std::clamp(canvasMenuProgress + (canvasMenuOpen ? menuStep : -menuStep), 0.0f, 1.0f); + if (lastChromeTick == 0) lastChromeTick = now; + const float chromeStep = std::min(1.0f, (now - lastChromeTick) / kCanvasChromeMs); + lastChromeTick = now; + const float chromeTarget = screen == Screen::Canvas && drawing ? 0.0f : 1.0f; + if (chromeTarget < canvasChromeVisible) canvasChromeVisible = std::max(chromeTarget, canvasChromeVisible - chromeStep); + else canvasChromeVisible = std::min(chromeTarget, canvasChromeVisible + chromeStep); + handleKeys(); +} + +void App::handleKeys() { + if (screen == Screen::AppletWarning) { + if (buttonsDown & (BA | BB | BPLUS)) running = false; + return; + } + if (updateInProgress) return; + if (modal == Modal::UpdateAvailable) { + if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); } + else if (buttonsDown & BA) startUpdateDownload(); + return; + } + if (modal == Modal::TextInput) { + if (editingField == Field::None) return; + return; + } + if (modal == Modal::GalleryNotice) { + if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); } + else if (buttonsDown & BA) confirmGalleryLeave(); + return; + } + if (modal == Modal::GalleryTerms) { + if (buttonsDown & BB) { modal = Modal::None; screen = Screen::Library; } + else if (buttonsDown & BA) { + galleryTermsAccepted = true; + std::ofstream accepted(join(dataRoot, "gallery-terms-accepted"), std::ios::binary | std::ios::trunc); + if (accepted) accepted << "1\n"; + modal = Modal::None; + } + return; + } + if (modal == Modal::GalleryOffline) { + if (buttonsDown & (BB | BA)) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); } + return; + } + if (modal == Modal::ConfirmDelete) { + if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); } + else if (buttonsDown & BZR) deleteDocument(); + return; + } + if (modal == Modal::GalleryPostChoice) { + if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); } + else if (buttonsDown & BX) { modal = Modal::None; performGalleryUpload(pendingGalleryPublishIndex, true); } + else if (buttonsDown & BY) { modal = Modal::None; performGalleryUpload(pendingGalleryPublishIndex, false); } + return; + } + if (modal == Modal::PhotoProbe && (buttonsDown & BB)) return; + if ((modal == Modal::Photo || modal == Modal::PhotoChoice || modal == Modal::PhotoAlbum || modal == Modal::PhotoUpload) && (buttonsDown & BB)) { + if (modal == Modal::PhotoAlbum) { modal = Modal::PhotoChoice; return; } + if (serverStartRunning && serverStartTask == ServerStartTask::Photo) return; + closePhotoCapture(); + return; + } + if (modal == Modal::PhotoChoice) { + if (buttonsDown & BA) { openPhotoAlbum(); } + else if (buttonsDown & BY) { + openPhotoUpload(); + photoModalTransitionDim = true; + photoModalTransitionDimAt = SDL_GetTicks(); + } + else if ((buttonsDown & BX) && photoProbeHasCamera) { modal = Modal::Photo; modalTransitionStart = SDL_GetTicks(); } + return; + } + if (modal == Modal::Audio && (buttonsDown & BB)) { + if (serverStartRunning && serverStartTask == ServerStartTask::Audio) return; + closeAudioMenu(); + return; + } + if (modal == Modal::Branding && (buttonsDown & BB)) { + if (serverStartRunning && serverStartTask == ServerStartTask::Branding) return; + closeBrandingMenu(); + return; + } + if (modal != Modal::None) { + if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); return; } + if (modal == Modal::Options) { + int speed = docLoaded ? documentSpeed(doc.metadata) : defaultSpeed; + if (buttonsDown & BLEFT) speed = std::max(1, speed - 1); + if (buttonsDown & BRIGHT) speed = std::min(8, speed + 1); + if (buttonsDown & (BLEFT | BRIGHT)) { + defaultSpeed = speed; + defaultFps = kSpeedFps[static_cast(speed - 1)]; + if (docLoaded) { + doc.metadata.fps = static_cast(defaultFps); + doc.metadata.speed = static_cast(speed); + markDirty(); + if (doc.metadata.bgmFollowsSpeed) rebuildAudio(); + } + savePreferences(); + } + if (buttonsDown & BUP) { onion = !onion; savePreferences(); } + if (buttonsDown & BDOWN) { canvasGrid = !canvasGrid; savePreferences(); } + if (buttonsDown & BY) { copyToNewPage = !copyToNewPage; savePreferences(); } + if ((buttonsDown & BX) && docLoaded) { doc.metadata.loopPlayback = !doc.metadata.loopPlayback; markDirty(); } + if (buttonsDown & BA) modal = Modal::None; + return; + } + if (modal == Modal::Audio) { + if (buttonsDown & BLEFT) { audioTrack = std::max(0, audioTrack - 1); audioEraseArmedTrack = -1; } + if (buttonsDown & BRIGHT) { audioTrack = std::min(3, audioTrack + 1); audioEraseArmedTrack = -1; } + if ((buttonsDown & BY) && audioTrack == 0) { + doc.metadata.bgmFollowsSpeed = !doc.metadata.bgmFollowsSpeed; + markDirty(); + rebuildAudio(); + } + if (buttonsDown & BA) { + if (audioTrack < static_cast(doc.audioTracks.size()) && + !doc.audioTracks[static_cast(audioTrack)].pcm.empty()) { + if (Mix_Playing(4)) Mix_HaltChannel(4); + else if (audioReady && trackChunks[static_cast(audioTrack)]) + Mix_PlayChannel(4, trackChunks[static_cast(audioTrack)], 0); + } else showToast("No audio in this track yet"); + } + if ((buttonsDown & (BPLUS | BMINUS)) && audioTrack > 0) { + while (doc.audioTracks.size() <= static_cast(audioTrack)) { + sppm::AudioTrack blank; + blank.name = "SE" + std::to_string(doc.audioTracks.size()); + blank.sampleRate = 48000; + blank.channels = 2; + doc.audioTracks.push_back(std::move(blank)); + } + auto& triggers = doc.audioTracks[static_cast(audioTrack)].frameTriggers; + const auto cue = static_cast(frame); + auto it = std::lower_bound(triggers.begin(), triggers.end(), cue); + if (it != triggers.end() && *it == cue) triggers.erase(it); + else triggers.insert(it, cue); + markDirty(); + } + return; + } + if (modal == Modal::Folder) { + if (buttonsDown & BUP) modalSelection = std::max(0, modalSelection - 1); + if (buttonsDown & BDOWN) modalSelection = std::min(5, modalSelection + 1); + if (buttonsDown & BA) { + std::vector folders; + if (DIR* dir = opendir(notesRoot.c_str())) { + while (dirent* ent = readdir(dir)) { + const std::string name = ent->d_name; + if (name.empty() || name[0] == '.') continue; + struct stat st{}; + if (stat(join(notesRoot, name).c_str(), &st) == 0 && (st.st_mode & S_IFDIR)) folders.push_back(name); + } + closedir(dir); + } + std::sort(folders.begin(), folders.end()); + if (!folders.empty()) { + folder = folders[static_cast(modalSelection) % folders.size()]; + selected = 0; + scanLibrary(); + modal = Modal::None; + playEvent("folder_open", "folder_open"); + } + } + return; + } + if (modal == Modal::Export && (buttonsDown & (BUP | BDOWN | BLEFT | BRIGHT))) modalSelection = (modalSelection + 1) % 2; + if ((modal == Modal::ConfirmDelete || modal == Modal::ConfirmGalleryDelete) && + (buttonsDown & (BLEFT | BRIGHT))) modalSelection = (modalSelection + 1) % 2; + if (buttonsDown & BA) { + if (modal == Modal::Export) { exportDocument(modalSelection); modal = Modal::None; } + else if (modal == Modal::ConfirmGalleryDelete) { + if (modalSelection == 1) deleteGalleryItem(); + else modal = Modal::None; + } else if (modal == Modal::Help) modal = Modal::None; + else if (modal == Modal::TextInput) applyText(); + } + return; + } + if (buttonsDown & BB) { + switch (screen) { + case Screen::Title: break; + case Screen::Library: screen = Screen::Title; return; + case Screen::Details: screen = Screen::Library; return; + case Screen::Sequence: screen = Screen::Canvas; return; + case Screen::Canvas: + layer = (layer + 1) % 4; + playEvent("tool_layer", "draw_tool_btn_on"); + return; + case Screen::Viewer: + stopPlayback(); + screen = galleryViewing ? Screen::GalleryDetails : Screen::Details; + return; + case Screen::Settings: screen = settingsReturnScreen; return; + case Screen::Gallery: screen = Screen::Library; return; + case Screen::GalleryDetails: + if (galleryProfileDetailMode) { galleryProfileDetailMode = false; screen = Screen::GalleryProfile; } + else screen = Screen::Gallery; + return; + case Screen::GalleryProfile: leaveGalleryProfile(); return; + case Screen::GalleryComments: screen = Screen::GalleryDetails; return; + case Screen::CommentCanvas: screen = Screen::GalleryComments; return; + case Screen::AppletWarning: running = false; return; + } + } + if (buttonsDown & BMINUS) { + if (screen == Screen::Title) return; + if (screen == Screen::Settings) { screen = settingsReturnScreen; return; } + if (screen == Screen::Library || screen == Screen::Gallery) screen = Screen::Title; + else if (screen == Screen::Canvas || screen == Screen::Viewer) { stopPlayback(); screen = Screen::Sequence; } + else if (screen == Screen::GalleryComments) screen = Screen::GalleryDetails; + else if (screen == Screen::CommentCanvas) screen = Screen::GalleryComments; + else if (screen == Screen::Sequence) { closeDocument(); screen = Screen::Library; } + else if (screen == Screen::Details || screen == Screen::GalleryDetails) { + if (screen == Screen::GalleryDetails && galleryProfileDetailMode) { + galleryProfileDetailMode = false; + screen = Screen::GalleryProfile; + } else screen = (screen == Screen::GalleryDetails ? Screen::Gallery : Screen::Library); + } + } + if (screen == Screen::Title) { + if (buttonsDown & BR) { settingsReturnScreen = Screen::Title; screen = Screen::Settings; return; } + if ((buttonsDown & (BZL | BZR)) == (BZL | BZR) || (buttonsHeld & (BZL | BZR)) == (BZL | BZR) || (buttonsDown & BA)) { + screen = Screen::Library; + selected = 0; + playEvent("folder_open", "folder_open"); + } + } else if (screen == Screen::Library || screen == Screen::Gallery) { + const bool galleryMode = screen == Screen::Gallery; + if (!galleryMode && (buttonsDown & BZR)) { showGalleryNotice(); return; } + if (galleryMode && (buttonsDown & BZL) && galleryPage > 0) { + --galleryPage; gallerySelected = 0; galleryStatus = "Loading Gallery…"; refreshGallery(); return; + } + if (galleryMode && (buttonsDown & BZR) && (galleryPage + 1) * 6 < galleryTotal) { + ++galleryPage; gallerySelected = 0; galleryStatus = "Loading Gallery…"; refreshGallery(); return; + } + if (!galleryMode && (buttonsDown & BR)) { + settingsReturnScreen = Screen::Library; + screen = Screen::Settings; + return; + } + if (!galleryMode && (buttonsDown & BPLUS)) { + newDocument(); + return; + } + auto& selection = galleryMode ? gallerySelected : selected; + const auto& items = galleryMode ? gallery : library; + if (buttonsDown & BLEFT) { if (selection > 0) --selection; } + if (buttonsDown & BRIGHT) { if (selection + 1 < items.size()) ++selection; } + if (buttonsDown & BUP) { if (selection >= 3) selection -= 3; } + if (buttonsDown & BDOWN) { if (selection + 3 < items.size()) selection += 3; } + if (buttonsDown & BY) { + if (!galleryMode && !items.empty()) openItem(selection, true); + else if (galleryMode) addGalleryCoin(); + } + if (buttonsDown & BA) { + if (!items.empty()) { + if (galleryMode) screen = Screen::GalleryDetails; + else { expandMetadata = false; screen = Screen::Details; } + } + } + if (buttonsDown & BX && !galleryMode && !items.empty()) { modalSelection = 0; modal = Modal::ConfirmDelete; } + } else if (screen == Screen::Details || screen == Screen::GalleryDetails) { + const bool galleryOwner = screen == Screen::GalleryDetails && !gallery.empty() && + gallery[gallerySelected % gallery.size()].ownerIdentity == sppm::identityFingerprint(signingIdentity.publicIdentity); + if (buttonsDown & BX && galleryOwner) { modalSelection = 0; modal = Modal::ConfirmGalleryDelete; } + if (buttonsDown & BY) { + if (screen == Screen::GalleryDetails) openGalleryComments(); + else { openItem(selected, true); } + } + if (buttonsDown & BA) { + if (screen == Screen::GalleryDetails) openGalleryItem(gallerySelected); + else { openItem(selected, false); screen = Screen::Viewer; } + } + if (buttonsDown & BZL && screen == Screen::Details && !library.empty()) { modalSelection = 0; modal = Modal::ConfirmDelete; } + } else if (screen == Screen::Sequence) { + if (buttonsDown & BPLUS) { settingsReturnScreen = Screen::Sequence; screen = Screen::Settings; return; } + if (buttonsDown & BLEFT) setFrame(frame ? frame - 1 : 0); + if (buttonsDown & BRIGHT) { + const bool createsFirstHeldPage = frame + 1 >= doc.frames.size(); + if (frame + 1 < doc.frames.size()) setFrame(frame + 1); + else insertFrame(false); + const std::uint32_t period = std::max(1, static_cast(std::lround(1000.0 / sppm::frameRate(doc.metadata)))); + rightDpadNextRepeatAt = SDL_GetTicks() + period * (createsFirstHeldPage ? 3u : 1u); + rightDpadRepeating = (buttonsHeld & BRIGHT) != 0; + } + if (buttonsDown & BUP) setFrame(0, true, "frame_first"); + if (buttonsDown & BDOWN) setFrame(doc.frames.size() - 1, true, "frame_last"); + if (buttonsDown & BA) startPlayback(); + if (buttonsDown & BY) { + if (doc.metadata.locked) showToast("This Switchnote is locked and cannot be edited"); + else screen = Screen::Canvas; + } + if ((buttonsDown & BX) && !doc.metadata.locked) insertFrame(false); + if (buttonsDown & BZL) { modalSelection = 0; modal = Modal::ConfirmDelete; } + } else if (screen == Screen::Canvas || screen == Screen::Viewer) { + if (screen == Screen::Viewer && (buttonsDown & BLEFT)) { if (frame) setFrame(frame - 1); } + if (screen == Screen::Viewer && (buttonsDown & BRIGHT)) { + const bool createsFirstHeldPage = frame + 1 >= doc.frames.size(); + if (frame + 1 < doc.frames.size()) setFrame(frame + 1); + else if (screen == Screen::Canvas) insertFrame(false); + if (screen == Screen::Canvas && modal == Modal::None) { + const std::uint32_t period = std::max(1, static_cast(std::lround(1000.0 / sppm::frameRate(doc.metadata)))); + rightDpadNextRepeatAt = SDL_GetTicks() + period * (createsFirstHeldPage ? 3u : 1u); + rightDpadRepeating = (buttonsHeld & BRIGHT) != 0; + } + } + if (screen == Screen::Canvas && (buttonsDown & BUP)) { zoom = std::min(zoom * 1.25f, 8.0f); } + if (screen == Screen::Canvas && (buttonsDown & BDOWN)) { zoom = std::max(zoom / 1.25f, 0.5f); } + if (buttonsDown & BA) { if (playing) stopPlayback(); else startPlayback(); } + if (screen == Screen::Viewer && (buttonsDown & BB)) { stopPlayback(); screen = Screen::Sequence; } + if (screen == Screen::Canvas) { + const bool dpadDown = (buttonsDown & (BLEFT | BRIGHT)) != 0; + if (((buttonsHeld & BY) && dpadDown) || ((buttonsDown & BY) && (buttonsHeld & (BLEFT | BRIGHT))) || + ((buttonsUp & BY) && dpadDown)) { + canvasYChordUsed = true; + if (buttonsDown & BLEFT) undo(); + if (buttonsDown & BRIGHT) redo(); + if ((buttonsDown & BY) && (buttonsHeld & BLEFT)) undo(); + if ((buttonsDown & BY) && (buttonsHeld & BRIGHT)) redo(); + } + const bool suppressDpadForY = canvasYChordUsed || (buttonsHeld & BY) != 0; + if ((buttonsUp & BY) && !canvasYChordUsed) { + activeTool = activeTool == Tool::Pen ? Tool::Brush : activeTool == Tool::Brush ? Tool::Eraser : Tool::Pen; + playEvent(activeTool == Tool::Pen ? "tool_pen" : activeTool == Tool::Brush ? "tool_brush" : "tool_eraser", "draw_tool_btn_on"); + } + if (buttonsUp & BY) canvasYChordUsed = false; + if (buttonsDown & BZL) undo(); + if (buttonsDown & BZR) redo(); + if (buttonsDown & BX) { + pushUndo(); + auto& pixels = doc.frames[frame].layers[static_cast(layer)]; + std::fill(pixels.begin(), pixels.end(), 0); + markDirty(); + playEvent("clear_layer", "erase"); + } + if ((buttonsDown & BLEFT) && !suppressDpadForY) { if (frame) setFrame(frame - 1); } + if ((buttonsDown & BRIGHT) && !suppressDpadForY) { + const bool createsFirstHeldPage = frame + 1 >= doc.frames.size(); + if (frame + 1 < doc.frames.size()) setFrame(frame + 1); + else insertFrame(false); + const std::uint32_t period = std::max(1, static_cast(std::lround(1000.0 / sppm::frameRate(doc.metadata)))); + rightDpadNextRepeatAt = SDL_GetTicks() + period * (createsFirstHeldPage ? 3u : 1u); + rightDpadRepeating = (buttonsHeld & BRIGHT) != 0; + } + if (buttonsDown & BL) screen = Screen::Sequence; + if (buttonsDown & BPLUS) insertFrame(false); + } + } else if (screen == Screen::Settings) { + if (buttonsDown & BB) screen = settingsReturnScreen; + } else if (screen == Screen::GalleryProfile) { + const int profilePages = 1 + (galleryProfile.switchnotes + 5) / 6; + if ((buttonsDown & BLEFT) && galleryProfilePage > 0) + loadGalleryProfile(galleryProfile.identity, galleryProfileReturnScreen, galleryProfilePage - 1); + if ((buttonsDown & BRIGHT) && galleryProfilePage + 1 < profilePages) + loadGalleryProfile(galleryProfile.identity, galleryProfileReturnScreen, galleryProfilePage + 1); + if ((buttonsDown & BA) && galleryProfilePage > 0 && !gallery.empty()) { + galleryProfileDetailMode = true; + screen = Screen::GalleryDetails; + } else if ((buttonsDown & BA) && galleryProfilePage == 0 && !galleryProfileIsOwn) { + updateGalleryRelationship(galleryProfile.followed ? "unfollow" : "follow"); + } + if (buttonsDown & BX) updateGalleryRelationship(galleryProfile.blocked ? "unblock" : "block"); + if ((buttonsDown & BY) && galleryProfileIsOwn) beginText(Field::GalleryBio, galleryProfile.bio); + } else if (screen == Screen::GalleryComments) { + if (buttonsDown & BUP) galleryCommentScroll = std::max(0, galleryCommentScroll - 90); + if (buttonsDown & BDOWN) galleryCommentScroll += 90; + if (buttonsDown & BY) screen = Screen::CommentCanvas; + if (buttonsDown & BB) screen = Screen::GalleryDetails; + } else if (screen == Screen::CommentCanvas) { + if (buttonsDown & BB) screen = Screen::GalleryComments; + if (buttonsDown & BY) postGalleryComment(); + if (buttonsDown & BX) { commentPixels.assign(sppm::kPixelsPerLayer, 0); commentDirty = true; } + } + if (wheel && screen == Screen::Canvas) zoom = std::clamp(zoom + wheel * 0.2f, 0.5f, 8.0f); + if (wheel && screen == Screen::GalleryComments) + galleryCommentScroll = std::max(0, galleryCommentScroll - wheel * 90); + if (screen == Screen::Canvas) { + const int maxPanX = std::max(0, (static_cast(sppm::kWidth * 2.0f * zoom) - W) / 2); + const int maxPanY = std::max(0, (static_cast(sppm::kHeight * 2.0f * zoom) - H) / 2); + panX = std::clamp(panX, -maxPanX, maxPanX); + panY = std::clamp(panY, -maxPanY, maxPanY); + } +} + +void App::render() { + // Some custom canvas effects alter the renderer blend mode. Restore alpha + // blending every frame so loading dimmers and translucent UI always work. + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + if (screen == Screen::Canvas || screen == Screen::Viewer) SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255); + else SDL_SetRenderDrawColor(renderer, 245, 246, 248, 255); + SDL_RenderClear(renderer); + switch (screen) { + case Screen::Title: renderTitle(); break; + case Screen::Library: renderLibrary(); break; + case Screen::Details: renderDetails(false); break; + case Screen::Settings: renderSettings(); break; + case Screen::Sequence: renderSequence(); break; + case Screen::Canvas: renderCanvas(); break; + case Screen::Viewer: renderViewer(); break; + case Screen::Gallery: renderGallery(); break; + case Screen::GalleryDetails: renderDetails(true); break; + case Screen::GalleryProfile: renderGalleryProfile(); break; + case Screen::GalleryComments: renderComments(); break; + case Screen::CommentCanvas: renderCommentCanvas(); break; + case Screen::AppletWarning: renderAppletWarning(); break; + } + if (modal != lastRenderedModal) { + modalTransitionStart = SDL_GetTicks(); + if (modal == Modal::None) { + modalVisual = lastRenderedModal; + modalClosing = modalVisual != Modal::None; + } else { + modalVisual = modal; + modalClosing = false; + playEvent("popup", "popup"); + } + lastRenderedModal = modal; + } + if (modal != Modal::None) renderModal(); + else if (modalClosing) { + const Modal savedModal = modal; + modal = modalVisual; + renderModal(); + modal = savedModal; + } + renderToast(); + const Uint8 transitionAlpha = screen.whiteAlpha(SDL_GetTicks()); + if (transitionAlpha) fill(0, 0, W, H, SDL_Color{255, 255, 255, transitionAlpha}); + renderLoadingOverlay(); + SDL_RenderPresent(renderer); + if (touchHoverExpiryPending && !pointerDown) { + pointerX = pointerY = -1; + touchHoverExpiryPending = false; + } +} + +void App::renderAppletWarning() { + background(); + const SDL_Color panel{239, 119, 122, 255}; + const SDL_Color darkRed{194, 24, 24, 255}; + rounded(53, 43, 1174, 640, 28, SDL_Color{0, 0, 0, 35}); + rounded(59, 47, 1162, 636, 28, WHITE); + rounded(61, 49, 1158, 632, 26, panel); + centered("Cannot run in Applet Mode", 210, 145, 860, 100, 48, darkRed, true); + centered(appDisplayName + " cannot run in Applet Mode.", 165, 268, 950, 52, 28, darkRed, false); + text("Please run the app from the Homebrew Menu in App Override mode, or", 168, 366, 27, darkRed, false); + text("install the homebrew NSP file.", 168, 402, 27, darkRed, false); + const bool hovered = pointerX >= 566 && pointerX < 714 && pointerY >= 524 && pointerY < 584; + const int press = pointerDown && hovered ? 2 : 0; + rounded(578, 530 + press, 124, 53, 28, SDL_Color{0, 0, 0, 35}); + rounded(572, 524 + press, 136, 54, 28, WHITE); + rounded(574, 526 + press, 132, 50, 26, panel); + centered("Exit", 574, 526 + press, 132, 50, 24, darkRed, false); + if (hit(572, 524, 136, 54)) { clickConsumed = true; running = false; } +} + +void App::renderTitle() { + background(); + if (brandingLogoTexture) { + int sourceW = 0, sourceH = 0; + SDL_QueryTexture(brandingLogoTexture, nullptr, nullptr, &sourceW, &sourceH); + const float scale = sourceW > 0 && sourceH > 0 ? std::min(640.0f / sourceW, 218.0f / sourceH) : 1.0f; + const int logoW = static_cast(sourceW * scale), logoH = static_cast(sourceH * scale); + drawTexture(brandingLogoTexture, 640 - logoW / 2, 252 - logoH / 2, logoW, logoH); + } + else { + centered(appDisplayName, 240, 190, 800, 126, 72, CREAM, true); + centered(appDisplayName, 240, 184, 800, 126, 72, ORANGE, true); + } + if (actionPill("ui/icons/CanvasMenu_ConfigToggle.png", "Settings", 1022, 36, 214, 48, false, true, "")) { + settingsReturnScreen = Screen::Title; + screen = Screen::Settings; + } + const float focus = 0.5f + 0.5f * std::sin(pulse * 2.4f); + const auto promptTone = static_cast(220 + 10 * focus); + const bool promptHovered = pointerX >= 474 && pointerX < 806 && pointerY >= 469 && pointerY < 531; + const bool promptPressed = pointerDown && promptHovered; + const int promptLift = hoverLift(474, 469, promptHovered); + const int promptPress = promptPressed ? 4 : 0; + const int promptY = -promptLift + promptPress; + rounded(474, 474 + promptY, 332, 64, 14, SDL_Color{0, 0, 0, 38}); + rounded(474, 469 + promptY, 332, 62, 14, WHITE); + rounded(480, 475 + promptY, 320, 50, 10, SDL_Color{255, promptTone, static_cast(197 + 8 * focus), 255}); + centered("Press", 516, 475 + promptY, 100, 50, 25, ORANGE, true); + auto promptIcon = [&](const char* path, int x) { + SDL_Texture* texture = image(path); + if (!texture) return; + int sourceW = 0, sourceH = 0; + SDL_QueryTexture(texture, nullptr, nullptr, &sourceW, &sourceH); + const float iconScale = sourceW && sourceH ? std::min(42.0f / sourceW, 32.0f / sourceH) : 1.0f; + const int iconW = std::max(1, static_cast(std::lround(sourceW * iconScale))); + const int iconH = std::max(1, static_cast(std::lround(sourceH * iconScale))); + drawTexture(texture, x + (42 - iconW) / 2, 484 + (32 - iconH) / 2 + promptY, iconW, iconH); + }; + promptIcon("ui/icons/ButtonZLText.png", 622); + centered("+", 666, 475 + promptY, 34, 50, 25, ORANGE, true); + promptIcon("ui/icons/ButtonZRText.png", 698); + if (hit(474, 469, 332, 62)) { clickConsumed = true; screen = Screen::Library; selected = 0; playEvent("folder_open", "folder_open"); } + centered("© 2026 RexiMemo!", 390, 634, 500, 44, 25, ORANGE, false); + text("Ver 0.1.51", 1130, 663, 18, ORANGE); +} + +void App::renderLibrary() { + background(); + drawImage("ui/FolderButton_Active.png", 40, 25, 68, 68); + drawImage("ui/FolderButton_New.png", 122, 25, 68, 68); + centered("Folders", 36, 88, 76, 21, 14, GRAY, true); + centered("New Folder", 115, 88, 84, 21, 13, GRAY, true); + if (pill("Switchnote Gallery", 788, 40, 205, 47, true, 19)) showGalleryNotice(); + if (actionPill("ui/icons/FrameNew.png", "New Switchnote", 1017, 40, 220, 47, false, true, "")) newDocument(); + if (hit(40, 24, 70, 70)) { clickConsumed = true; modalSelection = 0; modal = Modal::Folder; playEvent("folder_open", "folder_open"); } + if (hit(122, 24, 70, 70)) { clickConsumed = true; beginText(Field::FolderName, ""); } + const int start = static_cast(page) * 6; + if (library.empty()) { + rounded(305, 190, 670, 315, 20, SDL_Color{0, 0, 0, 28}); + rounded(305, 182, 670, 315, 20, WHITE); + drawImage("ui/icons/FrameNew.png", 594, 224, 92, 92); + centered("\"" + folder + "\" is empty", 355, 328, 570, 42, 27, INK, true); + centered("Switchnotes added to this folder will show here.", 345, 374, 590, 40, 18, GRAY); + if (actionPill("ui/icons/FrameNew.png", "Create Switchnote", 500, 434, 280, 50)) newDocument(); + } + for (int cell = 0; cell < 6; ++cell) { + const int index = start + cell; + if (index >= static_cast(library.size())) break; + const int col = cell % 3; + const int row = cell / 3; + const int x = 104 + col * 372; + const int y = 153 + row * 232; + const bool selectedNow = static_cast(index) == selected; + const bool hovered = pointerX >= x - 5 && pointerX < x + 326 && pointerY >= y - 4 && pointerY < y + 194; + const int lift = hoverLift(x, y, hovered); + const SDL_Color border = selectedNow ? ORANGE : SDL_Color{210, 214, 220, 255}; + rounded(x - 5, y + 4 - lift, 331, 198, 15, SDL_Color{0, 0, 0, 48}); + rounded(x - 5, y - 4 - lift, 331, 198, 15, WHITE); + // Every Switchnote preview sits on the same solid paper background, + // regardless of whether its content uses transparent pixels. + rounded(x + 3, y + 4 - lift, 315, 182, 8, WHITE); + const Item& item = library[static_cast(index)]; + if (item.sample) drawImage("art/" + item.artwork, x + 3, y + 4 - lift, 315, 182, true); + else if (item.path == docPath && docLoaded) drawCurrentFrame(x + 3, y + 4 - lift, 315, 182); + else drawTexture(thumbTexture(item), x + 3, y + 4 - lift, 315, 182); + roundedOutline(x - 1, y - lift, 323, 190, 14, 4, border); + if (!item.sample && item.locked) drawLockIcon(x + 10, y + 10 - lift); + if (selectedNow) { + const int pop = lift; + rounded(x + 142 - pop / 2, y + 170 - pop / 2 - lift, 58 + pop, 58 + pop, 30 + pop / 2, SDL_Color{0, 0, 0, 42}); + rounded(x + 138 - pop / 2, y + 164 - pop / 2 - lift, 58 + pop, 58 + pop, 30 + pop / 2, WHITE); + rounded(x + 143 - pop / 2, y + 169 - pop / 2 - lift, 48 + pop, 48 + pop, 24 + pop / 2, CREAM); + drawPlayTriangle(x + 158 - pop / 2, y + 182 - pop / 2 - lift, 20 + pop, ORANGE); + } + if (hit(x - 5, y - 4, 331, 198)) { + selected = static_cast(index); page = selected / 6; clickConsumed = true; + if (selectedNow) { expandMetadata = false; screen = Screen::Details; } + else playCue("button_on"); + } + } + const int totalPages = std::max(1, static_cast((library.size() + 5) / 6)); + if (totalPages > 1 && page > 0 && pill("<", 25, 327, 60, 78, false, 34)) --page; + if (totalPages > 1 && page + 1 < totalPages && pill(">", 1195, 327, 60, 78, false, 34)) ++page; + if (imageButton("ui/BackButton.png", 39, 620, 65, 65, "button_off")) screen = Screen::Title; + for (int i = 0; i < totalPages; ++i) rounded(582 + i * 18, 653, 12, 12, 6, i == page ? ORANGE : CREAM); + if (actionPill("ui/icons/ButtonYText.png", "Edit", 920, 635, 126, 50, false, !library.empty())) openItem(selected, true); + if (actionPill("ui/icons/ButtonAText.png", "Details", 1060, 635, 164, 50, false, !library.empty())) { expandMetadata = false; screen = Screen::Details; } + if (hit(40, 25, 68, 68)) { modalSelection = 0; modal = Modal::Folder; clickConsumed = true; } + if (hit(122, 25, 68, 68)) { beginText(Field::FolderName, ""); clickConsumed = true; } +} + +void App::renderSettings() { + background(); + if (imageButton("ui/BackButton.png", 39, 35, 65, 65, "button_off")) screen = settingsReturnScreen; + text("Settings", 132, 40, 34, INK, true); + + rounded(103, 139, 514, 150, 18, SDL_Color{0, 0, 0, 28}); + rounded(103, 132, 514, 150, 18, WHITE); + rounded(126, 165, 84, 84, 3, PALE_GREEN); + if (profileTexture) drawTexture(profileTexture, 126, 165, 84, 84); + else centered(userName.empty() ? "?" : utf8Prefix(userName, 1), 126, 165, 84, 84, 40, WHITE, true); + text("Nickname", 232, 162, 17, GRAY, true); + text(userName, 232, 196, 28, INK, true); + + rounded(645, 139, 532, 150, 18, SDL_Color{0, 0, 0, 28}); + rounded(645, 132, 532, 150, 18, LIGHT); + text("Creator FSID", 677, 158, 18, GRAY, true); + text(myFsid, 677, 193, 30, SDL_Color{125, 132, 146, 255}, true); + + std::string freeSpace = "Unavailable"; +#ifdef __SWITCH__ + FsFileSystem sd{}; + s64 freeBytes = 0; + if (R_SUCCEEDED(fsOpenSdCardFileSystem(&sd))) { + if (R_SUCCEEDED(fsFsGetFreeSpace(&sd, "/", &freeBytes))) { + const double gib = static_cast(freeBytes) / (1024.0 * 1024.0 * 1024.0); + std::ostringstream amount; + amount.setf(std::ios::fixed); + amount.precision(gib >= 10.0 ? 0 : 1); + amount << gib << " GB free"; + freeSpace = amount.str(); + } + fsFsClose(&sd); + } +#endif + rounded(103, 329, 1074, 120, 18, SDL_Color{0, 0, 0, 28}); + rounded(103, 322, 1074, 120, 18, WHITE); + text("SD Free Space", 138, 350, 19, GRAY, true); + text(freeSpace, 138, 385, 31, INK, true); + if (pill(std::string("Pen Stabilisation: ") + (penStabilisationEnabled ? "On" : "Off"), + 103, 466, 330, 48, penStabilisationEnabled, 18)) { + penStabilisationEnabled = !penStabilisationEnabled; + savePreferences(); + } + if (penStabilisationEnabled) { + text("Method", 480, 474, 18, GRAY); + if (pill(penStabilisationAfter ? "After" : "Real Time", 556, 466, 185, 48, true, 18)) { + penStabilisationAfter = !penStabilisationAfter; + afterStrokePoints.clear(); + realtimeStrokeInitialized = false; + savePreferences(); + } + text("Stabilisation", 103, 535, 18, GRAY); + const int sliderX = 263, sliderY = 550, sliderW = 650; + rounded(sliderX, sliderY + 8, sliderW, 8, 4, LIGHT); + rounded(sliderX, sliderY + 8, sliderW * penStabilisationStrength / 100, 8, 4, GREEN); + const int handleX = sliderX + sliderW * penStabilisationStrength / 100; + rounded(handleX - 13, sliderY, 26, 26, 13, WHITE); + rounded(handleX - 8, sliderY + 5, 16, 16, 8, GREEN); + text(std::to_string(penStabilisationStrength), 932, 544, 18, GREEN); + if (pointerDown && pointerX >= sliderX - 8 && pointerX <= sliderX + sliderW + 8 && + pointerY >= sliderY - 8 && pointerY <= sliderY + 34) { + penStabilisationStrength = std::clamp((pointerX - sliderX) * 100 / sliderW, 0, 100); + } + if (hit(sliderX - 8, sliderY - 8, sliderW + 16, 42)) { + clickConsumed = true; + penStabilisationStrength = std::clamp((pointerX - sliderX) * 100 / sliderW, 0, 100); + savePreferences(); + } + } + centered("Import from branding.config in the app folder · exports/branding.config", 300, 590, 650, 30, 15, GRAY); + if (pill("Credits", 1000, 582, 177, 42, false, 17)) { modal = Modal::Credits; modalTransitionStart = SDL_GetTicks(); } + if (pill("Import Branding", 103, 634, 250, 56, false, 17)) importBrandingConfig(); + if (pill("Export Branding", 367, 634, 250, 56, false, 17)) exportBrandingConfig(); + if (pill("Check for Updates", 631, 634, 246, 56, false, 17)) startUpdateCheck(true); + if (actionPill("ui/icons/CanvasMenu_ConfigToggle.png", "Branding Config Portal", 891, 634, 286, 56, false, true, "")) { + requestBrandingConfigServer(); modal = Modal::Branding; modalTransitionStart = SDL_GetTicks(); + } +} + +void App::renderGallery() { + background(); + const std::string sortLabel = galleryPopular ? "Popular " + notePlural : "Recent " + notePlural; + if (pill(sortLabel, 40, 40, 274, 48, true, 18)) { + galleryPopular = !galleryPopular; + galleryPage = 0; + gallerySelected = 0; + galleryStatus = "Loading Gallery…"; + refreshGallery(); + } + rounded(1173, 37, 68, 68, 12, SDL_Color{0, 0, 0, 40}); + rounded(1173, 32, 68, 68, 12, WHITE); + rounded(1178, 37, 58, 58, 3, PALE_GREEN); + if (profileCornerTexture) drawTexture(profileCornerTexture, 1178, 37, 58, 58); + else centered(userName.empty() ? "?" : utf8Prefix(userName, 1), 1178, 37, 58, 58, 30, GREEN, true); + rounded(1212, 78, 29, 29, 15, SDL_Color{10, 12, 15, 255}); + centered("+", 1212, 76, 29, 29, 25, WHITE, true); + if (hit(1173, 32, 68, 68)) { + clickConsumed = true; + loadGalleryProfile(sppm::identityFingerprint(signingIdentity.publicIdentity), Screen::Gallery); + } + + if (gallery.empty()) { + rounded(330, 226, 620, 258, 20, SDL_Color{0, 0, 0, 28}); + rounded(330, 218, 620, 258, 20, WHITE); + const bool emptyGallery = galleryTotal == 0 && galleryStatus == "No Switchnotes have been shared yet."; + centered(emptyGallery ? "No Switchnotes yet" : "Gallery unavailable", + 370, 270, 540, 42, 25, INK, true); + centered(emptyGallery ? "" : utf8Prefix(galleryStatus, 70), + 365, 321, 550, 52, 17, GRAY); + if (pill("Refresh", 520, 400, 240, 48, true, 18)) { + galleryStatus = "Loading Gallery…"; + refreshGallery(); + } + } else { + for (int cell = 0; cell < 6; ++cell) { + const int index = cell; + if (index >= static_cast(gallery.size())) break; + const int col = cell % 3; + const int row = cell / 3; + const int x = 104 + col * 372; + const int y = 153 + row * 232; + const bool selectedNow = static_cast(index) == gallerySelected; + const bool hovered = pointerX >= x - 5 && pointerX < x + 326 && pointerY >= y - 4 && pointerY < y + 194; + const int lift = hoverLift(x, y, hovered); + const SDL_Color border = selectedNow ? GREEN : SDL_Color{210, 214, 220, 255}; + rounded(x - 5, y + 4 - lift, 331, 198, 15, SDL_Color{0, 0, 0, 48}); + rounded(x - 5, y - 4 - lift, 331, 198, 15, WHITE); + rounded(x + 3, y + 4 - lift, 315, 182, 8, WHITE); + drawTexture(galleryPreviewTexture(gallery[static_cast(index)]), x + 3, y + 4 - lift, 315, 182); + roundedOutline(x - 1, y - lift, 323, 190, 12, 4, border); + if (gallery[static_cast(index)].locked) drawLockIcon(x + 10, y + 10 - lift); + rounded(x + 228, y + 158 - lift, 94, 30, 15, SDL_Color{255, 237, 190, 245}); + drawImage("ui/icons/Coin.png", x + 236, y + 162 - lift, 22, 22); + text(std::to_string(gallery[static_cast(index)].coins), x + 265, y + 162 - lift, 17, ORANGE, true); + if (selectedNow) { + const int pop = lift; + rounded(x + 142 - pop / 2, y + 170 - pop / 2 - lift, 58 + pop, 58 + pop, 30 + pop / 2, SDL_Color{0, 0, 0, 42}); + rounded(x + 138 - pop / 2, y + 164 - pop / 2 - lift, 58 + pop, 58 + pop, 30 + pop / 2, WHITE); + rounded(x + 143 - pop / 2, y + 169 - pop / 2 - lift, 48 + pop, 48 + pop, 24 + pop / 2, PALE_GREEN); + drawPlayTriangle(x + 158 - pop / 2, y + 182 - pop / 2 - lift, 20 + pop, WHITE); + } + if (hit(x - 5, y - 4, 331, 198)) { + clickConsumed = true; + gallerySelected = static_cast(index); + if (selectedNow) screen = Screen::GalleryDetails; + else playCue("button_on"); + } + } + const int totalPages = std::max(1, (galleryTotal + 5) / 6); + if (totalPages > 1 && galleryPage > 0 && pill("<", 25, 327, 60, 78, false, 34)) { + --galleryPage; gallerySelected = 0; galleryStatus = "Loading Gallery…"; refreshGallery(); + } + if (totalPages > 1 && galleryPage + 1 < totalPages && pill(">", 1195, 327, 60, 78, false, 34)) { + ++galleryPage; gallerySelected = 0; galleryStatus = "Loading Gallery…"; refreshGallery(); + } + if (totalPages > 1) for (int i = 0; i < totalPages; ++i) + rounded(640 - totalPages * 9 + i * 18, 653, 12, 12, 6, i == galleryPage ? GREEN : PALE_GREEN); + if (actionPill("ui/icons/ButtonYGalleryText.png", "Add Coins", 932, 635, 174, 50, true)) addGalleryCoin(); + if (actionPill("ui/icons/ButtonAGalleryText.png", "Details", 1118, 635, 120, 50, true)) screen = Screen::GalleryDetails; + } + if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65, "button_off")) { + galleryProfileActive = false; + screen = Screen::Library; + } +} + +void App::renderGalleryProfile() { + background(); + const std::string username = galleryProfile.username.empty() + ? (galleryLoadRunning ? "Loading profile…" : "Gallery User") : galleryProfile.username; + if (pill(galleryProfile.followed ? "Following" : "Follow", 40, 40, 166, 48, true, 19, !galleryProfileIsOwn)) + updateGalleryRelationship(galleryProfile.followed ? "unfollow" : "follow"); + if (dangerPill(galleryProfile.blocked ? "Unblock" : "Block", 1074, 40, 166, 48, 19, !galleryProfileIsOwn)) + updateGalleryRelationship(galleryProfile.blocked ? "unblock" : "block"); + if (galleryProfileIsOwn && galleryProfilePage == 0 && pill("Edit Bio", 224, 40, 166, 48, true, 19)) { + beginText(Field::GalleryBio, galleryProfile.bio); + } + + if (galleryProfilePage == 0) { + rounded(104, 146, 1072, 474, 22, SDL_Color{0, 0, 0, 30}); + rounded(98, 136, 1084, 474, 22, WHITE); + const int avatarX = 132, avatarY = 173, avatarSize = 164; + rounded(avatarX - 4, avatarY - 4, avatarSize + 8, avatarSize + 8, 19, GREEN); + rounded(avatarX, avatarY, avatarSize, avatarSize, 15, WHITE); + SDL_Texture* avatar = galleryProfileIsOwn ? profileTexture : galleryProfileAvatarTexture; + if (avatar) drawTexture(avatar, avatarX + 3, avatarY + 3, avatarSize - 6, avatarSize - 6); + else centered(username.empty() ? "?" : utf8Prefix(username, 1), avatarX + 3, avatarY + 3, + avatarSize - 6, avatarSize - 6, 70, GREEN, true); + text(utf8Prefix(username, 24), 324, 174, 54, GREEN, true); + const std::string bio = galleryProfile.bio.empty() ? "No bio added" : galleryProfile.bio; + text(utf8Prefix(bio, 58), 326, 247, 25, galleryProfile.bio.empty() ? GRAY : INK); + rounded(128, 350, 1024, 4, 2, GREEN); + + auto statCard = [&](int x, int y, const std::string& value, const std::string& label) { + rounded(x + 2, y + 4, 392, 82, 10, SDL_Color{0, 0, 0, 30}); + rounded(x, y, 392, 82, 10, GREEN); + rounded(x + 2, y + 2, 388, 78, 8, WHITE); + centered(value, x + 10, y + 3, 372, 48, 37, GREEN, true); + centered(label, x + 10, y + 47, 372, 28, 16, PALE_GREEN, true); + }; + statCard(182, 374, std::to_string(galleryProfile.switchnotes) + " " + brandString("Switchnotes"), + "Uploaded to " + appDisplayName + " Gallery"); + statCard(706, 374, std::to_string(galleryProfile.views) + " Views", "Total on all uploaded " + brandString("Switchnotes")); + statCard(182, 474, std::to_string(galleryProfile.coins) + " Coins", "Added across all uploaded " + brandString("Switchnotes")); + statCard(706, 474, std::to_string(galleryProfile.followers) + " Followers", "Following this user on Gallery"); + } else { + if (gallery.empty()) { + rounded(330, 226, 620, 258, 20, SDL_Color{0, 0, 0, 28}); + rounded(330, 218, 620, 258, 20, WHITE); + centered(galleryLoadRunning ? "Loading uploads…" : "No uploads on this page", + 370, 320, 540, 42, 25, INK, true); + } + for (int cell = 0; cell < 6 && cell < static_cast(gallery.size()); ++cell) { + const int col = cell % 3, row = cell / 3; + const int x = 104 + col * 372, y = 153 + row * 232; + const bool selectedNow = static_cast(cell) == gallerySelected; + const int lift = hoverLift(x, y, pointerX >= x - 5 && pointerX < x + 326 && pointerY >= y - 4 && pointerY < y + 194); + rounded(x - 5, y + 4 - lift, 331, 198, 15, SDL_Color{0, 0, 0, 48}); + rounded(x - 5, y - 4 - lift, 331, 198, 15, WHITE); + rounded(x + 3, y + 4 - lift, 315, 182, 8, WHITE); + drawTexture(galleryPreviewTexture(gallery[static_cast(cell)]), x + 3, y + 4 - lift, 315, 182); + roundedOutline(x - 1, y - lift, 323, 190, 14, 4, selectedNow ? GREEN : SDL_Color{210, 214, 220, 255}); + if (gallery[static_cast(cell)].locked) drawLockIcon(x + 10, y + 10 - lift); + rounded(x + 228, y + 158 - lift, 94, 30, 15, SDL_Color{255, 237, 190, 245}); + drawImage("ui/icons/Coin.png", x + 236, y + 162 - lift, 22, 22); + text(std::to_string(gallery[static_cast(cell)].coins), x + 265, y + 162 - lift, 17, ORANGE); + if (selectedNow) { + rounded(x + 137, y + 163 - lift, 59, 59, 30, SDL_Color{0, 0, 0, 42}); + rounded(x + 133, y + 157 - lift, 59, 59, 30, WHITE); + rounded(x + 138, y + 162 - lift, 49, 49, 25, PALE_GREEN); + drawPlayTriangle(x + 153, y + 174 - lift, 21, WHITE); + } + if (hit(x - 5, y - 4, 331, 198)) { + clickConsumed = true; + if (selectedNow) { galleryProfileDetailMode = true; screen = Screen::GalleryDetails; } + else { gallerySelected = static_cast(cell); playCue("button_on"); } + } + } + if (gallery.size() > 0 && actionPill("ui/icons/ButtonAGalleryText.png", "Details", 1100, 635, 138, 50, true)) { + galleryProfileDetailMode = true; + screen = Screen::GalleryDetails; + } + } + + const int pageCount = 1 + (std::max(galleryProfile.switchnotes, galleryTotal) + 5) / 6; + if (pageCount > 1) { + const bool hasPrevious = galleryProfilePage > 0; + const bool hasNext = galleryProfilePage + 1 < pageCount; + if (pill("<", 24, 326, 60, 78, false, 34, hasPrevious) && hasPrevious) + loadGalleryProfile(galleryProfile.identity, galleryProfileReturnScreen, galleryProfilePage - 1); + if (pill(">", 1196, 326, 60, 78, true, 34, hasNext) && hasNext) + loadGalleryProfile(galleryProfile.identity, galleryProfileReturnScreen, galleryProfilePage + 1); + } + if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65, "button_off")) leaveGalleryProfile(); + const int dotCount = std::max(1, std::min(10, pageCount)); + for (int i = 0; i < dotCount; ++i) { + const int pageForDot = dotCount == pageCount ? i : i * (pageCount - 1) / std::max(1, dotCount - 1); + rounded(640 - dotCount * 13 + i * 26, 655, 18, 18, 9, pageForDot == galleryProfilePage ? GREEN : PALE_GREEN); + } +} + +void App::renderDetails(bool galleryMode) { + background(); + if (galleryMode && gallery.empty()) { + if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65, "button_off")) screen = Screen::Gallery; + return; + } + const Item* item = nullptr; + bool allowSpinoffs = false; + if (galleryMode) item = &gallery[gallerySelected % gallery.size()]; + else if (!library.empty()) item = &library[selected % library.size()]; + if (!item) { if (pill("Back", 40, 620, 100, 50, galleryMode)) screen = galleryMode ? Screen::Gallery : Screen::Library; return; } + const bool noteLocked = !galleryMode && ((docLoaded && docPath == item->path) ? doc.metadata.locked : item->locked); + const int ix = 108, iy = 260, iw = 338, ih = 197; + rounded(ix - 4, iy - 4, iw + 8, ih + 8, 14, WHITE); + rounded(ix, iy, iw, ih, 10, ORANGE); + rounded(ix + 4, iy + 4, iw - 8, ih - 8, 7, WHITE); + if (galleryMode) drawTexture(galleryPreviewTexture(*item), ix + 4, iy + 4, iw - 8, ih - 8); + else if (item->sample) drawImage("art/" + item->artwork, ix + 4, iy + 4, iw - 8, ih - 8, true); + else if (docLoaded && docPath == item->path) drawCurrentFrame(ix + 4, iy + 4, iw - 8, ih - 8); + else drawTexture(thumbTexture(*item), ix + 4, iy + 4, iw - 8, ih - 8); + if (galleryMode) { + if (item->locked) drawLockIcon(ix + 10, iy + 10); + rounded(508, 181, 665, 67, 13, SDL_Color{226, 230, 242, 255}); + text(utf8Prefix(item->title, 38), 525, 197, 28, INK, true); + rounded(508, 262, 665, 110, 13, SDL_Color{226, 230, 242, 255}); + text(item->description.empty() ? "No description added" : utf8Prefix(item->description, 48), + 527, 284, 24, SDL_Color{112, 120, 141, 255}); + rounded(508, 385, 665, 154, 13, SDL_Color{255, 234, 213, 255}); + text("Author", 526, 402, 24, ORANGE); + const bool isCurrentAuthor = item->ownerIdentity.empty() + ? item->author == userName + : item->ownerIdentity == sppm::identityFingerprint(signingIdentity.publicIdentity); + if (isCurrentAuthor && profileDetailTexture) drawTexture(profileDetailTexture, 932, 397, 32, 32); + text(utf8Prefix(item->author, 24), isCurrentAuthor && profileDetailTexture ? 975 : 930, 402, 24, ORANGE); + if (hit(925, 392, 235, 42) && !item->ownerIdentity.empty() && !galleryProfileDetailMode) { + clickConsumed = true; + loadGalleryProfile(item->ownerIdentity, Screen::GalleryDetails); + } + text("Creator FSID", 526, 441, 24, ORANGE); + text(item->fsid, 930, 441, 24, ORANGE); + text("Updated", 526, 480, 24, ORANGE); + text(dateText(item->modifiedUnixSeconds), 810, 480, 24, ORANGE); + text("Coins " + std::to_string(item->coins) + " Views " + std::to_string(item->views), 526, 554, 20, GREEN); + } else { + const bool isCurrentDocument = docLoaded && docPath == item->path; + if (isCurrentDocument ? doc.metadata.locked : item->locked) drawLockIcon(1127, 201); + allowSpinoffs = isCurrentDocument ? doc.metadata.allowSpinoffs : item->allowSpinoffs; + rounded(508, 183, 665, 67, 13, SDL_Color{226, 230, 242, 255}); + text(item->title, 524, 197, 27, INK, true); + if (!noteLocked) drawImage("ui/icons/Edit.png", 1114, 195, 47, 47); + rounded(508, 262, 665, 110, 13, SDL_Color{226, 230, 242, 255}); + const std::string description = isCurrentDocument && !doc.metadata.description.empty() + ? doc.metadata.description : !item->description.empty() ? item->description : "No description added"; + text(utf8Prefix(description, 42), 526, 284, 24, SDL_Color{112, 120, 141, 255}); + if (!noteLocked) drawImage("ui/icons/Edit.png", 1114, 292, 47, 47); + const std::string authorName = isCurrentDocument ? doc.metadata.authorName : item->author; + const std::string originalAuthorName = isCurrentDocument ? doc.metadata.originalAuthorName + : (item->originalAuthor.empty() ? item->author : item->originalAuthor); + const std::string authorFsid = isCurrentDocument ? doc.metadata.authorFsid : item->authorFsid; + const std::string originalAuthorFsid = isCurrentDocument ? doc.metadata.originalAuthorFsid : item->originalAuthorFsid; + const std::uint64_t modifiedAt = isCurrentDocument ? doc.metadata.modifiedUnixSeconds : item->modifiedUnixSeconds; + const std::uint64_t createdAt = isCurrentDocument ? doc.metadata.createdUnixSeconds : item->createdUnixSeconds; + centered(expandMetadata ? "Less Details" : "More Details", 540, 326, 590, 35, 23, SDL_Color{112, 120, 141, 255}); + rounded(508, 385, 665, expandMetadata ? 220 : 154, 13, SDL_Color{255, 234, 213, 255}); + if (expandMetadata) { + text("Author FSID", 526, 397, 19, ORANGE); + text(authorFsid.empty() ? "Unknown" : authorFsid, 820, 397, 19, ORANGE); + text("Original Author FSID", 526, 427, 19, ORANGE); + text(originalAuthorFsid.empty() ? "Unknown" : originalAuthorFsid, 820, 427, 19, ORANGE); + text("Last Edited", 526, 457, 19, ORANGE); + text(dateText(modifiedAt), 820, 457, 19, ORANGE); + text("First Created", 526, 487, 19, ORANGE); + text(dateText(createdAt), 820, 487, 19, ORANGE); + text("Switchnote FSID", 526, 527, 17, GREEN, true); + text(isCurrentDocument ? doc.metadata.currentId : item->currentId, 810, 527, 17, GREEN, true); + text("Root FSID", 526, 553, 17, GREEN, true); + text(isCurrentDocument ? doc.metadata.rootId : item->rootId, 810, 553, 17, GREEN, true); + } else { + text("Author", 526, 402, 24, ORANGE); + const bool isCurrentAuthor = item->ownerIdentity.empty() + ? authorName == userName + : item->ownerIdentity == sppm::identityFingerprint(signingIdentity.publicIdentity); + if (isCurrentAuthor && profileDetailTexture) drawTexture(profileDetailTexture, 932, 397, 32, 32); + text(authorName, isCurrentAuthor && profileDetailTexture ? 975 : 977, 402, 24, ORANGE); + text("Original Author", 526, 441, 24, ORANGE); + text(originalAuthorName, 977, 441, 24, ORANGE); + text("Last Edited", 526, 480, 24, ORANGE); + text(dateText(modifiedAt), 800, 480, 24, ORANGE); + } + } + if (imageButton(galleryMode ? "ui/BackButton_Gallery.png" : "ui/BackButton.png", 39, 620, 65, 65, "button_off")) { + if (galleryMode && galleryProfileDetailMode) { galleryProfileDetailMode = false; screen = Screen::GalleryProfile; } + else screen = galleryMode ? Screen::Gallery : Screen::Library; + } + if (galleryMode) { + const bool galleryOwner = item->ownerIdentity == sppm::identityFingerprint(signingIdentity.publicIdentity); + if (galleryOwner && dangerPill("Delete", 40, 36, 156, 50, 18)) { + modalSelection = 0; + modal = Modal::ConfirmGalleryDelete; + } + if (actionPill("ui/icons/ButtonYGalleryText.png", "Comments", 1040, 36, 198, 50, true)) openGalleryComments(); + if (actionPill("ui/icons/Coin.png", "Add Coin", 796, 635, 174, 50, true)) addGalleryCoin(); + if (pill("Save", 978, 635, 100, 50, true, 19)) saveGalleryItem(gallerySelected); + if (actionPill("ui/icons/ButtonAGalleryText.png", "Watch", 1088, 635, 150, 50, true)) openGalleryItem(gallerySelected); + } else { + if (pill("Upload to Gallery", 1022, 36, 216, 50, true, 17, !item->sample)) publishGalleryItem(selected); + if (actionPill("ui/icons/ButtonZLText.png", "Delete", 40, 36, 174, 50, false)) { modalSelection = 0; modal = Modal::ConfirmDelete; } + if (actionPill("ui/icons/ButtonYText.png", "Edit", 980, 635, 100, 50, false, !noteLocked)) openItem(selected, true); + if (actionPill("ui/icons/ButtonAText.png", "Watch", 1088, 635, 150, 50, false)) { openItem(selected, false); screen = Screen::Viewer; } + if (pill("Spin-off", 798, 635, 174, 50, false, 18, allowSpinoffs && !noteLocked)) { openItem(selected, false); forkDocument(); } + if (pill("Export", 580, 635, 174, 50, false, 19, !item->sample)) { modalSelection = 0; modal = Modal::Export; } + } + if (!galleryMode && !noteLocked && hit(1114, 195, 47, 47)) { + clickConsumed = true; + if (item->sample || !docLoaded || docPath != item->path) openItem(selected, true); + beginText(Field::Title, docLoaded ? doc.metadata.title : item->title); + } + if (!galleryMode && !noteLocked && hit(1114, 292, 47, 47)) { + clickConsumed = true; + if (item->sample || !docLoaded || docPath != item->path) openItem(selected, true); + beginText(Field::Description, docLoaded ? doc.metadata.description : ""); + } + if (!galleryMode && hit(508, 262, 665, 110)) { clickConsumed = true; expandMetadata = !expandMetadata; } + if (hit(ix - 4, iy - 4, iw + 8, ih + 8) && !galleryMode) { clickConsumed = true; openItem(selected, false); screen = Screen::Viewer; } +} + +bool App::loadSample(const Item& item, sppm::Document& out) { + SDL_Surface* input = IMG_Load(join(assetsRoot, "art/" + item.artwork).c_str()); + if (!input) return false; + SDL_Surface* rgba = SDL_ConvertSurfaceFormat(input, SDL_PIXELFORMAT_RGBA32, 0); + SDL_FreeSurface(input); + if (!rgba) return false; + if (rgba->w != sppm::kWidth || rgba->h != sppm::kHeight) { + SDL_FreeSurface(rgba); + return false; + } + out = sppm::Document{}; + out.metadata.title = item.title; + out.metadata.description = "Sample animation supplied with Fakenote Studio."; + out.metadata.authorName = item.author; + out.metadata.originalAuthorName = item.author; + out.metadata.lastEditorName = item.author; + out.metadata.authorFsid = newId("sample-creator"); + out.metadata.currentId = newId("sample-note"); + if (out.metadata.authorFsid.empty() || out.metadata.currentId.empty()) { + SDL_FreeSurface(rgba); + return false; + } + out.metadata.originalAuthorFsid = out.metadata.authorFsid; + out.metadata.lastEditorFsid = out.metadata.authorFsid; + out.metadata.rootId = out.metadata.currentId; + out.metadata.createdUnixSeconds = out.metadata.modifiedUnixSeconds = nowSeconds(); + out.metadata.fps = 12; + out.metadata.speed = 6; + out.frames.emplace_back(); + std::unordered_map exact; + std::map frequency; + for (int y = 0; y < rgba->h; ++y) { + const auto* row = reinterpret_cast(static_cast(rgba->pixels) + y * rgba->pitch); + for (int x = 0; x < rgba->w; ++x) { + const std::uint32_t p = row[x]; + std::uint8_t r, g, b, a; + SDL_GetRGBA(p, rgba->format, &r, &g, &b, &a); + if (a >= 16) ++frequency[(static_cast(a) << 24) | (static_cast(r) << 16) | (static_cast(g) << 8) | b]; + } + } + std::vector> colors(frequency.begin(), frequency.end()); + std::sort(colors.begin(), colors.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); + if (colors.size() > 255) colors.resize(255); + for (std::size_t i = 0; i < colors.size(); ++i) { + const auto key = colors[i].first; + out.palette[i + 1] = {static_cast(key >> 16), static_cast(key >> 8), static_cast(key), static_cast(key >> 24)}; + exact[key] = static_cast(i + 1); + } + auto& pixels = out.frames[0].layers[3]; + for (int y = 0; y < rgba->h; ++y) { + const auto* row = reinterpret_cast(static_cast(rgba->pixels) + y * rgba->pitch); + for (int x = 0; x < rgba->w; ++x) { + std::uint8_t r, g, b, a; + SDL_GetRGBA(row[x], rgba->format, &r, &g, &b, &a); + if (a < 16) continue; + const std::uint32_t key = (static_cast(a) << 24) | (static_cast(r) << 16) | (static_cast(g) << 8) | b; + auto found = exact.find(key); + if (found != exact.end()) pixels[static_cast(y) * rgba->w + x] = found->second; + else { + int bestDistance = std::numeric_limits::max(); + std::uint8_t best = 1; + for (std::size_t i = 1; i <= colors.size(); ++i) { + const auto& c = out.palette[i]; + int dr = static_cast(r) - c.r, dg = static_cast(g) - c.g, db = static_cast(b) - c.b; + const int d = dr * dr + dg * dg + db * db; + if (d < bestDistance) { bestDistance = d; best = static_cast(i); } + } + pixels[static_cast(y) * rgba->w + x] = best; + } + } + } + SDL_FreeSurface(rgba); + return true; +} + +void App::openItem(std::size_t index, bool edit) { + LoadingScope loading(*this); + if (index >= library.size()) return; + if (docLoaded && dirty) saveDocument(); + const Item item = library[index]; + if (edit && item.locked) { showToast("This Switchnote is locked and cannot be edited"); return; } + stopPlayback(); + if (edit) { + color = 2; + activeTool = Tool::Pen; + paintMenu = colorMenu = false; + toolMenu = false; + brush = 4; + pattern = 0; + square = false; + layer = 0; + } + if (item.sample) { + sppm::Document sample; + if (!loadSample(item, sample)) { showToast("Could not load sample drawing"); return; } + if (edit) { + sppm::Document child; + const std::string childId = newId("spinoff"); + if (childId.empty()) { showToast("Secure random ID generation failed"); return; } + std::string forkError; + if (!sppm::createSpinoff(sample, userName, myFsid, childId, nowSeconds(), child, forkError)) { + showToast("Could not create sample spin-off: " + forkError); + return; + } + child.metadata.authorIdentity = signingIdentity.publicIdentity; + child.metadata.lastEditorIdentity = signingIdentity.publicIdentity; + child.metadata.title = item.title + " (spin-off)"; + doc = std::move(child); + color = blackInkIndex(doc.palette); + docPath.clear(); + docLoaded = true; + dirty = false; + galleryViewing = false; + frame = 0; + screen = Screen::Canvas; + invalidateFrames(); + rebuildAudio(); + showToast("Editing an included sample; save it as a spin-off"); + } else { + doc = std::move(sample); + docPath.clear(); + docLoaded = true; + galleryViewing = false; + frame = 0; + screen = Screen::Viewer; + invalidateFrames(); + rebuildAudio(); + } + return; + } + std::string error; + if (!sppm::loadSignedFile(item.path, doc, signingIdentity.legacyHmacKey, error)) { showToast("Could not open Switchnote: " + error); return; } + docPath = item.path; + if (edit && doc.metadata.locked) { docLoaded = false; docPath.clear(); showToast("This Switchnote is locked and cannot be edited"); return; } + docLoaded = true; + galleryViewing = false; + dirty = false; + frame = 0; + if (edit) color = blackInkIndex(doc.palette); + screen = edit ? Screen::Canvas : Screen::Viewer; + invalidateFrames(); + rebuildAudio(); +} + +void App::newDocument() { + if (docLoaded && dirty) saveDocument(); + const std::string id = newId("note"); + if (id.empty()) { showToast("Secure random ID generation failed"); return; } + stopPlayback(); + doc = sppm::Document{}; + rebuildAudio(); + doc.frames.emplace_back(); + doc.metadata.title = defaultSwitchnoteTitle(userName, noteSingular); + doc.metadata.authorName = userName; + doc.metadata.originalAuthorName = userName; + doc.metadata.lastEditorName = userName; + doc.metadata.authorFsid = myFsid; + doc.metadata.originalAuthorFsid = myFsid; + doc.metadata.lastEditorFsid = myFsid; + doc.metadata.authorIdentity = signingIdentity.publicIdentity; + doc.metadata.originalAuthorIdentity = signingIdentity.publicIdentity; + doc.metadata.lastEditorIdentity = signingIdentity.publicIdentity; + doc.metadata.currentId = id; + doc.metadata.rootId = doc.metadata.currentId; + doc.metadata.createdUnixSeconds = doc.metadata.modifiedUnixSeconds = nowSeconds(); + doc.metadata.fps = static_cast(defaultFps); + doc.metadata.speed = static_cast(defaultSpeed); + docPath.clear(); + docLoaded = true; + dirty = true; + frame = 0; + layer = 0; + color = blackInkIndex(doc.palette); + brush = 4; + pattern = 0; + square = false; + activeTool = Tool::Pen; + paintMenu = false; + colorMenu = toolMenu = false; + zoom = 1.0f; + panX = panY = 0; + undoStack.clear(); redoStack.clear(); + invalidateFrames(); + saveDocument(); + for (std::size_t i = 0; i < library.size(); ++i) { + if (library[i].path == docPath) { selected = i; page = static_cast(i / 6); break; } + } + screen = Screen::Canvas; + showToast("New Switchnote — draw now; use New to add frames"); + playEvent("note_new", "page_new"); +} + +bool App::saveDocument(bool force) { + LoadingScope loading(*this); + if (!docLoaded) return false; + if (doc.metadata.locked && dirty) { showToast("This Switchnote is locked and cannot be edited"); return false; } + if (!dirty && !force) return true; + if (docPath.empty()) { + // A sample spin-off receives its persistent file only when explicitly saved. + doc.metadata.title = doc.metadata.title.empty() ? defaultSwitchnoteTitle(userName, noteSingular) : doc.metadata.title; + docPath = join(join(notesRoot, folder), doc.metadata.currentId + ".sppm"); + } + doc.metadata.lastEditorName = userName; + doc.metadata.lastEditorFsid = myFsid; + doc.metadata.lastEditorIdentity = signingIdentity.publicIdentity; + doc.metadata.modifiedUnixSeconds = nowSeconds(); + std::string error; + if (sppm::savePublicSignedFile(docPath, doc, signingIdentity, error)) { + const std::string thumbnailKey = "thumb:" + docPath; + auto thumbnail = images.find(thumbnailKey); + if (thumbnail != images.end()) { + if (thumbnail->second) SDL_DestroyTexture(thumbnail->second); + images.erase(thumbnail); + } + scanLibrary(); + bool foundSavedItem = false; + for (std::size_t i = 0; i < library.size(); ++i) { + if (library[i].path == docPath) { + selected = i; + page = static_cast(i / 6); + foundSavedItem = true; + break; + } + } + if (!foundSavedItem) { + sppm::Document persisted; + std::string verifyError; + if (!sppm::loadSignedFile(docPath, persisted, signingIdentity.legacyHmacKey, verifyError)) { + showToast("Saved Switchnote could not be reopened: " + verifyError); + return false; + } + Item saved{}; + saved.title = persisted.metadata.title.empty() ? defaultSwitchnoteTitle(userName, noteSingular) : persisted.metadata.title; + saved.path = docPath; + saved.author = persisted.metadata.authorName; + saved.description = persisted.metadata.description; + saved.originalAuthor = persisted.metadata.originalAuthorName; + saved.authorFsid = persisted.metadata.authorFsid; + saved.originalAuthorFsid = persisted.metadata.originalAuthorFsid; + saved.ownerIdentity = sppm::identityFingerprint(persisted.metadata.authorIdentity); + saved.currentId = persisted.metadata.currentId; + saved.rootId = persisted.metadata.rootId; + saved.createdUnixSeconds = persisted.metadata.createdUnixSeconds; + saved.modifiedUnixSeconds = persisted.metadata.modifiedUnixSeconds; + saved.allowSpinoffs = persisted.metadata.allowSpinoffs; + library.push_back(std::move(saved)); + std::sort(library.begin(), library.end(), [](const Item& a, const Item& b) { + if (a.modifiedUnixSeconds != b.modifiedUnixSeconds) return a.modifiedUnixSeconds > b.modifiedUnixSeconds; + if (a.createdUnixSeconds != b.createdUnixSeconds) return a.createdUnixSeconds > b.createdUnixSeconds; + return a.path < b.path; + }); + for (std::size_t i = 0; i < library.size(); ++i) { + if (library[i].path == docPath) { + selected = i; + page = static_cast(i / 6); + foundSavedItem = true; + break; + } + } + } + if (!foundSavedItem) { + showToast("Switchnote saved, but could not be added to the library"); + return false; + } + dirty = false; + return true; + } + showToast("Save failed: " + error); + return false; +} + +void App::closeDocument() { + saveDocument(); + stopPlayback(); + docLoaded = false; + docPath.clear(); + frame = 0; + doc = sppm::Document{}; + rebuildAudio(); + invalidateFrames(); +} + +void App::deleteDocument() { + if (library.empty() || selected >= library.size()) return; + const Item item = library[selected]; + if (item.sample) { showToast("Bundled sample artwork cannot be deleted"); return; } + if (!item.path.empty() && std::remove(item.path.c_str()) != 0) { showToast("Could not delete this Switchnote"); return; } + if (docPath == item.path) { + stopPlayback(); + docLoaded = false; + dirty = false; + docPath.clear(); + doc = sppm::Document{}; + rebuildAudio(); + invalidateFrames(); + } + scanLibrary(); + screen = Screen::Library; + modal = Modal::None; + showToast("Switchnote deleted"); + playEvent("delete", "delete_complete"); +} + +void App::forkDocument() { + if (!docLoaded && !library.empty()) openItem(selected, false); + if (!docLoaded) return; + const std::string id = newId("spinoff"); + if (id.empty()) { showToast("Secure random ID generation failed"); return; } + sppm::Document child; + std::string error; + if (!sppm::createSpinoff(doc, userName, myFsid, id, nowSeconds(), child, error)) { + showToast("Spin-off unavailable: " + error); + return; + } + child.metadata.authorIdentity = signingIdentity.publicIdentity; + child.metadata.lastEditorIdentity = signingIdentity.publicIdentity; + stopPlayback(); + doc = std::move(child); + color = blackInkIndex(doc.palette); + rebuildAudio(); + doc.metadata.title = doc.metadata.title + " (spin-off)"; + docPath.clear(); + galleryViewing = false; + dirty = true; + frame = std::min(frame, doc.frames.size() - 1); + screen = Screen::Canvas; + saveDocument(); + scanLibrary(); + showToast("Spin-off created with the original author and lineage preserved"); +} + +void App::markDirty() { + dirty = true; + dirtyTick = SDL_GetTicks(); + ++docRevision; + invalidateFrames(); +} + +void App::invalidateFrames() { + for (auto& item : frameTextures) if (item.second.texture) SDL_DestroyTexture(item.second.texture); + frameTextures.clear(); + ++docRevision; +} + +SDL_Texture* App::frameTexture(std::size_t index) { + if (!docLoaded || index >= doc.frames.size()) return nullptr; + auto& cache = frameTextures[index]; + if (cache.texture && cache.revision == docRevision) return cache.texture; + if (cache.texture) SDL_DestroyTexture(cache.texture); + SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, sppm::kWidth, sppm::kHeight, 32, SDL_PIXELFORMAT_RGBA32); + if (!surface) return nullptr; + const auto& frameData = doc.frames[index]; + auto* pixels = static_cast(surface->pixels); + for (std::size_t i = 0; i < sppm::kPixelsPerLayer; ++i) { + std::uint32_t out = 0; + for (std::size_t l = sppm::kLayerCount; l-- > 0;) { + const std::uint8_t idx = frameData.layers[l][i]; + if (!idx) continue; + const auto& c = doc.palette[idx]; + if (!c.a) continue; + const std::uint8_t alpha = c.a; + std::uint8_t r, g, b, oldA; + SDL_GetRGBA(out, surface->format, &r, &g, &b, &oldA); + const int inv = 255 - alpha; + const int nextA = alpha + (oldA * inv + 127) / 255; + if (nextA == 0) { out = 0; continue; } + r = static_cast((c.r * alpha + r * oldA * inv / 255 + nextA / 2) / nextA); + g = static_cast((c.g * alpha + g * oldA * inv / 255 + nextA / 2) / nextA); + b = static_cast((c.b * alpha + b * oldA * inv / 255 + nextA / 2) / nextA); + out = SDL_MapRGBA(surface->format, r, g, b, static_cast(nextA)); + } + pixels[i] = out; + } + cache.texture = SDL_CreateTextureFromSurface(renderer, surface); + SDL_FreeSurface(surface); + if (cache.texture) { + SDL_SetTextureScaleMode(cache.texture, SDL_ScaleModeNearest); + SDL_SetTextureBlendMode(cache.texture, SDL_BLENDMODE_BLEND); + } + cache.revision = docRevision; + return cache.texture; +} + +void App::drawFrameImage(std::size_t index, int x, int y, int w, int h) { drawTexture(frameTexture(index), x, y, w, h); } + +void App::drawCurrentFrame(int x, int y, int w, int h, bool withOnion) { + if (!docLoaded || doc.frames.empty()) return; + if (withOnion && onion && frame > 0) { + SDL_Texture* previous = frameTexture(frame - 1); + if (previous) { + SDL_SetTextureColorMod(previous, 255, 96, 104); + SDL_SetTextureAlphaMod(previous, 55); + drawTexture(previous, x, y, w, h); + SDL_SetTextureColorMod(previous, 255, 255, 255); + SDL_SetTextureAlphaMod(previous, 255); + } + } + drawFrameImage(frame, x, y, w, h); +} + +void App::playControl(int x, int y, int w, int h) { + if (!playing) { + if (imagePill("ui/icons/Play.png", x, y, w, h, false, true, "")) startPlayback(); + return; + } + const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h; + const bool pressed = pointerDown && hovered; + const int lift = hoverLift(x, y, hovered); + rounded(x, y + 5 - lift + (pressed ? 4 : 0), w, h, h / 2, SDL_Color{0, 0, 0, 42}); + rounded(x - 3, y - 3 - lift + (pressed ? 4 : 0), w + 6, h + 6, h / 2 + 3, WHITE); + rounded(x, y - lift + (pressed ? 4 : 0), w, h, h / 2, CREAM); + const int barW = std::max(5, w / 10), barH = std::max(18, h / 3); + fill(x + w / 2 - barW - 3, y + (h - barH) / 2 - lift + (pressed ? 4 : 0), barW, barH, ORANGE); + fill(x + w / 2 + 3, y + (h - barH) / 2 - lift + (pressed ? 4 : 0), barW, barH, ORANGE); + if (hit(x, y, w, h)) { clickConsumed = true; stopPlayback(); } +} + +void App::setFrame(std::size_t index, bool playNavigationCue, const char* event) { + if (!docLoaded || doc.frames.empty()) return; + const std::size_t previous = frame; + frame = std::min(index, doc.frames.size() - 1); + if (playNavigationCue && frame != previous) { + if (event) playEvent(event, "button_on"); + else playEvent(frame > previous ? "frame_next" : "frame_previous", "button_on"); + } + if (playing) playFrameSound(); +} + +void App::startPlayback() { + if (!docLoaded || doc.frames.empty()) return; + // Play always means replay from the beginning. Reset audio before changing + // the cursor so BGM and frame-zero cues share the animation's time origin. + stopPlayback(false); + frame = 0; + playing = true; + lastPlaybackTick = SDL_GetTicks(); + playbackTickRemainder = 0; + playEvent("playback_start", "movie_play"); + playFrameSound(); +} + +void App::updatePlayback(std::uint32_t now) { + if (!playing || !docLoaded || doc.frames.empty()) return; + const double exactFrameDuration = 1000.0 / sppm::frameRate(doc.metadata); + // Advance against the original frame clock rather than resetting it to + // `now`; if a render takes longer than one frame, catch up and keep the + // displayed counter synchronized with the animation. + while (playing) { + const double accumulated = exactFrameDuration + playbackTickRemainder; + const std::uint32_t frameDuration = std::max(1, static_cast(std::floor(accumulated))); + if (now - lastPlaybackTick < frameDuration) break; + playbackTickRemainder = accumulated - frameDuration; + lastPlaybackTick += frameDuration; + if (frame + 1 < doc.frames.size()) setFrame(frame + 1, false); + else if (!doc.metadata.loopPlayback) stopPlayback(false); + else { + if (audioReady) for (int channel = 0; channel < 4; ++channel) Mix_HaltChannel(channel); + setFrame(0, false); + } + } +} + +void App::stopPlayback(bool playStopCue) { + if (playing && playStopCue) playEvent("playback_stop", "movie_stop"); + playing = false; + if (audioReady) { + for (int channel = 0; channel < 5; ++channel) Mix_HaltChannel(channel); + } + if (bgmSeekChunk) { Mix_FreeChunk(bgmSeekChunk); bgmSeekChunk = nullptr; } +} + +void App::startBgmAtCurrentFrame() { + if (!audioReady || !trackChunks[0]) return; + Mix_HaltChannel(0); + if (bgmSeekChunk) { Mix_FreeChunk(bgmSeekChunk); bgmSeekChunk = nullptr; } + if (!doc.metadata.bgmFollowsSpeed) { Mix_PlayChannel(0, trackChunks[0], 0); return; } + if (frame == 0) { Mix_PlayChannel(0, trackChunks[0], 0); return; } + const auto& buffer = trackAudioBuffers[0]; + const std::size_t sampleFrame = static_cast( + std::floor(frame * 48000.0 / sppm::frameRate(doc.metadata))); + const std::size_t offset = sampleFrame * 4; + if (offset + 4 > buffer.size()) return; + bgmSeekChunk = Mix_QuickLoad_RAW(const_cast(buffer.data() + offset), + static_cast(buffer.size() - offset)); + if (bgmSeekChunk) Mix_PlayChannel(0, bgmSeekChunk, 0); +} + +void App::rebuildAudio() { + if (!audioReady) return; + Mix_HaltChannel(4); + for (int channel = 0; channel < static_cast(trackChunks.size()); ++channel) Mix_HaltChannel(channel); + if (bgmSeekChunk) { Mix_FreeChunk(bgmSeekChunk); bgmSeekChunk = nullptr; } + for (auto* chunk : trackChunks) if (chunk) Mix_FreeChunk(chunk); + trackChunks.fill(nullptr); + for (auto& buffer : trackAudioBuffers) buffer.clear(); + for (std::size_t i = 0; i < doc.audioTracks.size() && i < trackChunks.size(); ++i) { + auto& track = doc.audioTracks[i]; + if (track.pcm.empty()) continue; + if (track.channels < 1 || track.channels > 2 || track.sampleRate < 8000 || track.sampleRate > 48000) continue; + SDL_AudioCVT cvt{}; + if (SDL_BuildAudioCVT(&cvt, AUDIO_S16SYS, track.channels, static_cast(track.sampleRate), AUDIO_S16SYS, 2, 48000) < 0) continue; + const auto bytes = track.pcm.size() * sizeof(std::int16_t); + if (bytes > static_cast(std::numeric_limits::max())) continue; + cvt.len = static_cast(bytes); + cvt.buf = static_cast(SDL_malloc(static_cast(cvt.len) * cvt.len_mult)); + if (!cvt.buf) continue; + std::memcpy(cvt.buf, track.pcm.data(), bytes); + if (cvt.needed) { + if (SDL_ConvertAudio(&cvt) != 0) { SDL_free(cvt.buf); continue; } + } else { + cvt.len_cvt = cvt.len; + } + if (cvt.len_cvt <= 0) { SDL_free(cvt.buf); continue; } + if (i == 0 && doc.metadata.bgmFollowsSpeed && cvt.len_cvt >= 4) { + const std::size_t inputFrames = static_cast(cvt.len_cvt) / 4; + const double currentFps = sppm::frameRate(doc.metadata); + const double referenceFps = track.referenceSpeed ? selectedFrameRate(track.referenceSpeed, 0) : + (track.referenceFps ? track.referenceFps : currentFps); + const double playbackRate = currentFps / referenceFps; + constexpr std::size_t maxPlaybackSamples = 32ULL * 1024 * 1024; + const std::size_t maxOutputFrames = maxPlaybackSamples / 2; + const std::size_t requestedFrames = std::max( + 1, static_cast(std::ceil(inputFrames / playbackRate))); + const std::size_t outputFrames = std::min(requestedFrames, maxOutputFrames); + trackAudioBuffers[i].resize(outputFrames * 4); + const auto* sourceSamples = reinterpret_cast(cvt.buf); + auto* outputSamples = reinterpret_cast(trackAudioBuffers[i].data()); + for (std::size_t outFrame = 0; outFrame < outputFrames; ++outFrame) { + const double sourcePosition = std::min(static_cast(inputFrames - 1), outFrame * playbackRate); + const std::size_t sourceFrame = static_cast(sourcePosition); + const std::size_t nextFrame = std::min(sourceFrame + 1, inputFrames - 1); + const double fraction = sourcePosition - sourceFrame; + for (int channel = 0; channel < 2; ++channel) { + const double a = sourceSamples[sourceFrame * 2 + channel]; + const double b = sourceSamples[nextFrame * 2 + channel]; + outputSamples[outFrame * 2 + channel] = static_cast(std::clamp( + static_cast(std::lround(a + (b - a) * fraction)), -32768, 32767)); + } + } + } else { + trackAudioBuffers[i].assign(cvt.buf, cvt.buf + cvt.len_cvt); + } + SDL_free(cvt.buf); + trackChunks[i] = Mix_QuickLoad_RAW(trackAudioBuffers[i].data(), static_cast(trackAudioBuffers[i].size())); + } + if (playing) startBgmAtCurrentFrame(); +} + +void App::playFrameSound() { + if (!audioReady || !docLoaded || !playing) return; + if (frame == 0 && trackChunks[0] && (doc.metadata.bgmFollowsSpeed || !Mix_Playing(0))) + startBgmAtCurrentFrame(); + for (std::size_t i = 1; i < doc.audioTracks.size() && i < trackChunks.size(); ++i) { + const auto& cuesAt = doc.audioTracks[i].frameTriggers; + if (std::find(cuesAt.begin(), cuesAt.end(), static_cast(frame)) != cuesAt.end() && trackChunks[i]) + Mix_PlayChannel(static_cast(i), trackChunks[i], 0); + } +} + +void App::pushUndo() { + if (!docLoaded || frame >= doc.frames.size() || layer < 0 || layer >= 4) return; + if (fillWaveActive) { fillWaveActive = false; fillWavePixels.clear(); } + undoStack.push_back({frame, layer, doc.frames[frame].layers[layer]}); + if (undoStack.size() > 32) undoStack.erase(undoStack.begin()); + redoStack.clear(); +} + +void App::beginFillWave(int x, int y) { + if (!docLoaded || frame >= doc.frames.size() || layer < 0 || layer >= 4 || + x < 0 || x >= sppm::kWidth || y < 0 || y >= sppm::kHeight) return; + auto& pixels = doc.frames[frame].layers[static_cast(layer)]; + const std::size_t seed = static_cast(y) * sppm::kWidth + x; + const std::uint8_t target = pixels[seed]; + const std::uint8_t replacement = activeTool == Tool::Eraser + ? 0 : static_cast(std::clamp(color, 1, 255)); + if (target == replacement) return; + + pushUndo(); + fillWavePixels.clear(); + fillWavePixels.reserve(pixels.size() / 4); + std::vector queue; + queue.reserve(pixels.size() / 4); + std::vector visited(pixels.size(), 0); + std::vector distances(pixels.size(), std::numeric_limits::max()); + queue.push_back(static_cast(seed)); + visited[seed] = 1; + distances[seed] = 0; + for (std::size_t cursor = 0; cursor < queue.size(); ++cursor) { + const std::uint32_t index = queue[cursor]; + const int px = static_cast(index % sppm::kWidth); + const int py = static_cast(index / sppm::kWidth); + fillWavePixels.emplace_back(index, distances[index]); + const std::array neighbors{{px > 0 ? static_cast(index) - 1 : -1, + px + 1 < sppm::kWidth ? static_cast(index) + 1 : -1, + py > 0 ? static_cast(index) - sppm::kWidth : -1, + py + 1 < sppm::kHeight ? static_cast(index) + sppm::kWidth : -1}}; + for (const int next : neighbors) { + if (next < 0 || visited[static_cast(next)] || pixels[static_cast(next)] != target) continue; + visited[static_cast(next)] = 1; + distances[static_cast(next)] = static_cast(distances[index] + 1); + queue.push_back(static_cast(next)); + } + } + std::sort(fillWavePixels.begin(), fillWavePixels.end(), [](const auto& a, const auto& b) { + return a.second < b.second; + }); + fillWaveFrame = frame; + fillWaveLayer = layer; + fillWaveColor = replacement; + fillWaveOriginX = x; + fillWaveOriginY = y; + fillWaveRadius = 0.0f; + fillWaveCursor = 0; + fillWaveTick = SDL_GetTicks(); + fillWaveActive = !fillWavePixels.empty(); +} + +void App::updateFillWave(std::uint32_t now) { + if (!fillWaveActive) return; + if (!docLoaded || frame != fillWaveFrame || layer != fillWaveLayer || frame >= doc.frames.size()) { + fillWaveActive = false; + fillWavePixels.clear(); + return; + } + const float elapsed = std::min(0.1f, (now - fillWaveTick) / 1000.0f); + fillWaveTick = now; + // 25 drawing pixels every 0.25 seconds (100 pixels/second), staying within + // the connected region instead of crossing line-art boundaries. + fillWaveRadius += elapsed * 100.0f; + auto& pixels = doc.frames[frame].layers[static_cast(layer)]; + bool changed = false; + const auto radius = static_cast(std::floor(fillWaveRadius)); + while (fillWaveCursor < fillWavePixels.size() && fillWavePixels[fillWaveCursor].second <= radius) { + pixels[fillWavePixels[fillWaveCursor].first] = fillWaveColor; + ++fillWaveCursor; + changed = true; + } + if (changed) markDirty(); + if (fillWaveCursor >= fillWavePixels.size()) { + fillWaveActive = false; + fillWavePixels.clear(); + } +} + +void App::undo() { + if (undoStack.empty() || !docLoaded) return; + if (fillWaveActive) { fillWaveActive = false; fillWavePixels.clear(); } + UndoState state = std::move(undoStack.back()); undoStack.pop_back(); + redoStack.push_back({state.frame, state.layer, doc.frames[state.frame].layers[state.layer]}); + doc.frames[state.frame].layers[state.layer] = std::move(state.pixels); + frame = state.frame; layer = state.layer; markDirty(); playEvent("undo", "undo"); +} + +void App::redo() { + if (redoStack.empty() || !docLoaded) return; + if (fillWaveActive) { fillWaveActive = false; fillWavePixels.clear(); } + UndoState state = std::move(redoStack.back()); redoStack.pop_back(); + undoStack.push_back({state.frame, state.layer, doc.frames[state.frame].layers[state.layer]}); + doc.frames[state.frame].layers[state.layer] = std::move(state.pixels); + frame = state.frame; layer = state.layer; markDirty(); playEvent("redo", "redo"); +} + +void App::insertFrame(bool paste) { + if (!docLoaded || doc.frames.size() >= sppm::kMaxFrames) { showToast("This Switchnote has reached its 9999 frame limit"); return; } + const std::size_t insertAt = std::min(frame + 1, doc.frames.size()); + sppm::Frame next; + if (paste && clipboardReady) next = frameClipboard; + else if (copyToNewPage && frame < doc.frames.size()) next = doc.frames[frame]; + doc.frames.insert(doc.frames.begin() + static_cast(insertAt), std::move(next)); + for (auto& track : doc.audioTracks) for (auto& cue : track.frameTriggers) if (cue >= insertAt) ++cue; + frame = insertAt; + markDirty(); playEvent(paste ? "frame_paste" : "frame_new", paste ? "pic_stamp" : "page_new"); +} + +void App::eraseFrame() { + if (!docLoaded || doc.frames.size() <= 1) { showToast("A Switchnote must keep at least one frame"); return; } + doc.frames.erase(doc.frames.begin() + static_cast(frame)); + for (auto& track : doc.audioTracks) { + track.frameTriggers.erase(std::remove(track.frameTriggers.begin(), track.frameTriggers.end(), static_cast(frame)), track.frameTriggers.end()); + for (auto& cue : track.frameTriggers) if (cue > frame) --cue; + } + frame = std::min(frame, doc.frames.size() - 1); + markDirty(); + playEvent("frame_erase", "draw_tool_btn_on"); +} + +void App::copyFrame() { + if (!docLoaded || frame >= doc.frames.size()) return; + frameClipboard = doc.frames[frame]; + clipboardReady = true; + playEvent("frame_copy", "pic_stamp"); + showToast("Frame copied"); +} + +SDL_Texture* App::thumbTexture(const Item& item) { + const std::string key = "thumb:" + item.path; + auto cached = images.find(key); + if (cached != images.end()) return cached->second; + sppm::Document source; + std::string error; + if (!sppm::loadSignedFile(item.path, source, signingIdentity.legacyHmacKey, error) || source.frames.empty()) return nullptr; + SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, sppm::kWidth, sppm::kHeight, 32, SDL_PIXELFORMAT_RGBA32); + if (!surface) return nullptr; + auto* out = static_cast(surface->pixels); + const auto& frameData = source.frames[std::min(source.metadata.coverFrame, source.frames.size() - 1)]; + for (std::size_t i = 0; i < sppm::kPixelsPerLayer; ++i) { + unsigned r = 0, g = 0, b = 0, a = 0; + for (std::size_t layerIndex = frameData.layers.size(); layerIndex-- > 0;) { + const auto& layerPixels = frameData.layers[layerIndex]; + const auto index = layerPixels[i]; + if (!index) continue; + const auto c = source.palette[index]; + const unsigned na = c.a + a * (255 - c.a) / 255; + if (na) { + r = (c.r * c.a + r * a * (255 - c.a) / 255) / na; + g = (c.g * c.a + g * a * (255 - c.a) / 255) / na; + b = (c.b * c.a + b * a * (255 - c.a) / 255) / na; + } + a = na; + } + out[i] = SDL_MapRGBA(surface->format, r, g, b, a); + } + SDL_Texture* result = SDL_CreateTextureFromSurface(renderer, surface); + SDL_FreeSurface(surface); + if (result) { + SDL_SetTextureScaleMode(result, SDL_ScaleModeNearest); + SDL_SetTextureBlendMode(result, SDL_BLENDMODE_BLEND); + } + images[key] = result; + return result; +} + +void App::renderSequence() { + background(); + if (!docLoaded) { if (pill("Back", 40, 620, 100, 48)) screen = Screen::Library; return; } + const bool canUndo = !undoStack.empty(); + const bool canRedo = !redoStack.empty(); + drawImage(canUndo ? "ui/CanvasUndoButton_Active.png" : "ui/CanvasUndoButton_Inactive.png", 20, 18, 68, 54); + drawImage(canRedo ? "ui/CanvasRedoButton_Active.png" : "ui/CanvasRedoButton_Inactive.png", 96, 18, 68, 54); + if (canUndo && hit(20, 18, 68, 54)) { clickConsumed = true; undo(); } + if (canRedo && hit(96, 18, 68, 54)) { clickConsumed = true; redo(); } + rounded(321, 36, 638, 362, 14, WHITE); + rounded(325, 40, 630, 354, 10, SDL_Color{210, 214, 220, 255}); + rounded(329, 44, 622, 346, 8, WHITE); + drawCurrentFrame(331, 46, 618, 342, true); + const std::array tools{{"New", "Erase", "Copy", "Paste"}}; + const std::array icons{{"FrameNew.png", "FrameErase.png", "FrameCopy.png", "FramePaste.png"}}; + for (int i = 0; i < 4; ++i) { + const int y = 22 + i * 66; + if (actionPill("ui/icons/" + icons[i], tools[i], 1090, y, 166, 45, false, i != 3 || clipboardReady, "")) { + if (i == 0) insertFrame(false); + else if (i == 1) eraseFrame(); + else if (i == 2) copyFrame(); + else insertFrame(true); + } + } + rounded(1110, 366, 146, 40, 20, SDL_Color{0, 0, 0, 38}); + rounded(1110, 361, 146, 40, 20, WHITE); + centered((frame + 1 < 10 ? "000" : frame + 1 < 100 ? "00" : "0") + std::to_string(frame + 1) + " / " + + (doc.frames.size() < 10 ? "000" : doc.frames.size() < 100 ? "00" : "0") + std::to_string(doc.frames.size()), + 1110, 361, 146, 40, 18, ORANGE, true); + fill(0, 420, W, 196, SDL_Color{16, 16, 18, 255}); + for (int x = 8; x < W; x += 28) { + rounded(x, 436, 14, 14, 3, WHITE); + rounded(x, 591, 14, 14, 3, WHITE); + } + const int visibleFrames = std::min(6, static_cast(doc.frames.size())); + const int gap = 14; + constexpr int cardW = 174; + constexpr int cardH = 112; + const int firstFrame = std::clamp(static_cast(frame) - 2, 0, + std::max(0, static_cast(doc.frames.size()) - visibleFrames)); + const int stripWidth = visibleFrames * cardW + std::max(0, visibleFrames - 1) * gap; + const int firstX = (W - stripWidth) / 2; + const int thumbY = 464; + for (int visible = 0; visible < visibleFrames; ++visible) { + const int idx = firstFrame + visible; + const int x = firstX + visible * (cardW + gap); + const int y = thumbY; + if (static_cast(idx) == frame) rounded(x - 5, y - 5, cardW + 10, cardH + 10, 14, ORANGE); + rounded(x, y, cardW, cardH, 10, WHITE); + drawFrameImage(static_cast(idx), x + 4, y + 4, cardW - 8, cardH - 8); + if (hit(x, y, cardW, cardH)) { setFrame(static_cast(idx)); clickConsumed = true; } + } + if (imageButton("ui/BackButton.png", 20, 635, 62, 62, "button_off")) screen = Screen::Canvas; + drawImage("ui/SeekBar.png", 371, 649, 540, 30); + const int seekX = 373 + (doc.frames.size() > 1 ? static_cast(frame * 535 / (doc.frames.size() - 1)) : 0); + drawImage("ui/SeekBarHandle.png", seekX - 8, 644, 16, 40); + if (hit(370, 640, 540, 48)) { + const float ratio = std::clamp((pointerX - 373.0f) / 535.0f, 0.0f, 1.0f); + setFrame(static_cast(ratio * (doc.frames.size() - 1))); + clickConsumed = true; + } + if (imagePill("ui/icons/SkipFirst.png", 913, 645, 55, 48, false, frame > 0, "")) setFrame(0, true, "frame_first"); + if (imagePill("ui/icons/SkipPrev.png", 974, 645, 55, 48, false, frame > 0, "")) setFrame(frame > 5 ? frame - 5 : 0, true, "frame_previous"); + playControl(1044, 635, 62, 62); + if (imagePill("ui/icons/SkipNext.png", 1116, 645, 55, 48, false, true, "")) { + if (frame + 1 < doc.frames.size()) setFrame(frame + 1); + else insertFrame(false); + } + if (imagePill("ui/icons/SkipLast.png", 1177, 645, 58, 48, false, frame + 1 < doc.frames.size(), "")) + setFrame(doc.frames.size() - 1, true, "frame_last"); +} + +bool switchnoteBrushPattern(int id, int x, int y); +bool switchnoteEraserContains(int slot, int dx, int dy); + +void App::renderCanvas() { + if (!docLoaded) { screen = Screen::Library; return; } + if (pointerReleased && !clickConsumed && (paintMenu || colorMenu || toolMenu)) { + const bool insidePopup = pointerX >= 836 && pointerX < 1192 && pointerY >= 76 && pointerY < 362; + const bool onTool = pointerX >= 1200 && pointerX < 1260 && pointerY >= 12 && pointerY < 72; + const bool onBrushDot = pointerX >= 1200 && pointerX < 1260 && pointerY >= 80 && pointerY < 140; + const bool onColorDot = pointerX >= 1200 && pointerX < 1260 && pointerY >= 147 && pointerY < 207; + if (!insidePopup && !onTool && !onBrushDot && !onColorDot) paintMenu = colorMenu = toolMenu = false; + } + background(); + const float scale = 2.0f * zoom; + const int cw = static_cast(sppm::kWidth * scale); + const int ch = static_cast(sppm::kHeight * scale); + const int cx = (W - cw) / 2 + panX; + const int cy = (H - ch) / 2 + panY; + rounded(cx - 4, cy - 4, cw + 8, ch + 8, 10, SDL_Color{205, 210, 218, 255}); + fill(cx, cy, cw, ch, WHITE); + drawCurrentFrame(cx, cy, cw, ch, true); + if (canvasGrid) { + SDL_Rect canvasClip{cx, cy, cw, ch}; + SDL_RenderSetClipRect(renderer, &canvasClip); + SDL_SetRenderDrawColor(renderer, 82, 99, 124, 42); + // One line per image pixel: at 2x or above this is the actual drawing + // pixel grid, not a coarse design grid. + for (int x = 0; x <= sppm::kWidth; ++x) { + const int px = cx + static_cast(x * cw / static_cast(sppm::kWidth)); + SDL_RenderDrawLine(renderer, px, cy, px, cy + ch); + } + for (int y = 0; y <= sppm::kHeight; ++y) { + const int py = cy + static_cast(y * ch / static_cast(sppm::kHeight)); + SDL_RenderDrawLine(renderer, cx, py, cx + cw, py); + } + SDL_RenderSetClipRect(renderer, nullptr); + } + if (fillWaveActive && fillWaveRadius > 0.0f) { + SDL_Rect rippleClip{cx, cy, cw, ch}; + SDL_RenderSetClipRect(renderer, &rippleClip); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + const int front = static_cast(std::floor(fillWaveRadius)); + const int pixelW = std::max(1, static_cast(std::ceil(cw / static_cast(sppm::kWidth)))); + const int pixelH = std::max(1, static_cast(std::ceil(ch / static_cast(sppm::kHeight)))); + // Repeated narrow bands ride the fill front to read as an outward ripple, + // not a single uniform expanding disk. + for (const auto& entry : fillWavePixels) { + const int behindFront = front - static_cast(entry.second); + if (behindFront < 0 || (behindFront > 2 && behindFront % 16 > 1)) continue; + SDL_SetRenderDrawColor(renderer, 255, 119, 43, behindFront <= 2 ? 235 : 155); + const int px = static_cast(entry.first % sppm::kWidth); + const int py = static_cast(entry.first / sppm::kWidth); + SDL_Rect dot{cx + static_cast(px * cw / static_cast(sppm::kWidth)), + cy + static_cast(py * ch / static_cast(sppm::kHeight)), pixelW, pixelH}; + SDL_RenderFillRect(renderer, &dot); + } + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_RenderSetClipRect(renderer, nullptr); + } + const int chromeTravel = static_cast(std::lround((1.0f - canvasChromeVisible) * kCanvasChromeTravel)); + const int upOffset = -chromeTravel; + const int rightOffset = chromeTravel; + const int downOffset = chromeTravel; + const int leftOffset = -chromeTravel; + for (int i = 0; i < 4; ++i) { + const char c = static_cast('A' + i); + const std::string path = std::string("ui/CanvasLayerButton") + c + ((layer == i) ? "_Active.png" : "_Inactive.png"); + drawImage(path, 20 + leftOffset, 238 + i * 65, 72, 54); + if (hit(20, 238 + i * 65, 72, 54)) { layer = i; clickConsumed = true; playEvent("tool_layer", "draw_tool_btn_on"); } + } + const bool canUndo = !undoStack.empty(); + const bool canRedo = !redoStack.empty(); + drawImage(canUndo ? "ui/CanvasUndoButton_Active.png" : "ui/CanvasUndoButton_Inactive.png", 20, 18 + upOffset, 68, 54); + drawImage(canRedo ? "ui/CanvasRedoButton_Active.png" : "ui/CanvasRedoButton_Inactive.png", 96, 18 + upOffset, 68, 54); + if (canUndo && hit(20, 18, 68, 54)) { clickConsumed = true; undo(); } + if (canRedo && hit(96, 18, 68, 54)) { clickConsumed = true; redo(); } + const auto ink = doc.palette[static_cast(std::clamp(color, 1, 255))]; + auto drawToolIcon = [&](Tool tool, int x, int y, int size) { + if (tool == Tool::Eraser) { + drawImage("ui/icons/EraserTool_HQ.png", x + size / 8, y + size / 8, + size * 3 / 4, size * 3 / 4); + return; + } + const char* icon = tool == Tool::Pen ? "ui/icons/PenTool.png" : "ui/icons/PaintTool.png"; + drawImage(icon, x, y, size, size); + }; + const int toolsX = 1204 + rightOffset; + rounded(toolsX - 3, 14, 58, 58, 15, WHITE); + rounded(toolsX, 17, 52, 52, 12, activeTool == Tool::Pen ? CREAM : LIGHT); + drawToolIcon(activeTool, toolsX, 17, 52); + if (hit(1204, 17, 52, 52)) { toolMenu = !toolMenu; paintMenu = colorMenu = false; clickConsumed = true; playEvent("tool_pen", "draw_tool_btn_on"); } + rounded(toolsX, 84, 52, 52, 12, LIGHT); + const int dotSize = std::clamp(brush + 5, 8, 24); + const int displayedDotSize = activeTool == Tool::Brush ? (advancedBrush ? 24 : 12) : + activeTool == Tool::Eraser ? (eraserSlot == 0 ? 8 : eraserSlot < 4 ? 14 : 22) : dotSize; + rounded(toolsX + 26 - displayedDotSize / 2, 110 - displayedDotSize / 2, displayedDotSize, displayedDotSize, displayedDotSize / 2, + ink.a ? SDL_Color{ink.r, ink.g, ink.b, 255} : DRAW_BLACK); + if (hit(1204, 84, 52, 52)) { paintMenu = !paintMenu; colorMenu = toolMenu = false; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on"); } + rounded(toolsX, 151, 52, 52, 26, WHITE); + rounded(toolsX + 3, 154, 46, 46, 23, SDL_Color{210, 214, 220, 255}); + rounded(toolsX + 7, 158, 38, 38, 19, ink.a ? SDL_Color{ink.r, ink.g, ink.b, 255} : DRAW_BLACK); + if (hit(1204, 151, 52, 52)) { colorMenu = !colorMenu; paintMenu = toolMenu = false; clickConsumed = true; playEvent("color_menu", "popup"); } + rounded(1114, 587 + downOffset, 142, 39, 20, SDL_Color{0, 0, 0, 32}); + rounded(1114, 582 + downOffset, 142, 39, 20, WHITE); + centered((frame + 1 < 10 ? "000" : frame + 1 < 100 ? "00" : "0") + std::to_string(frame + 1) + " / " + + (doc.frames.size() < 10 ? "000" : doc.frames.size() < 100 ? "00" : "0") + std::to_string(doc.frames.size()), + 1114, 582 + downOffset, 142, 39, 18, ORANGE, true); + if (imageButton("ui/BackButton.png", 20, 635 + downOffset, 64, 62, "button_off")) { + if (saveDocument(true)) { + expandMetadata = false; + screen = Screen::Details; + clickConsumed = true; + } + } + const char* menuIcon = canvasMenuOpen ? "ui/CanvasMenuButton_Close.png" : "ui/CanvasMenuButton.png"; + if (imageButton(menuIcon, 92, 635 + downOffset, 64, 62, "button_off")) { + canvasMenuOpen = !canvasMenuOpen; + clickConsumed = true; + } + if (actionPill("ui/icons/CanvasMenu_ConfigToggle.png", "Options", 474, 640 + downOffset, 142, 48, false, true, "")) { + modal = Modal::Options; + clickConsumed = true; + } + if (actionPill("ui/icons/FrameErase.png", "Erase", 630, 640 + downOffset, 162, 48, false, true, "")) { + auto& currentLayer = doc.frames[frame].layers[static_cast(layer)]; + if (std::any_of(currentLayer.begin(), currentLayer.end(), [](std::uint8_t value) { return value != 0; })) { + pushUndo(); + std::fill(currentLayer.begin(), currentLayer.end(), 0); + markDirty(); + playEvent("frame_erase", "edit_page_remove"); + } + clickConsumed = true; + } + if (imagePill("ui/icons/SkipFirst.png", 904, 645 + downOffset, 55, 48, false, frame > 0, "")) setFrame(0, true, "frame_first"); + if (imagePill("ui/icons/SkipPrev.png", 965, 645 + downOffset, 55, 48, false, frame > 0, "")) setFrame(frame > 5 ? frame - 5 : 0, true, "frame_previous"); + playControl(1036, 635 + downOffset, 62, 62); + if (imagePill("ui/icons/SkipNext.png", 1108, 645 + downOffset, 55, 48, false, true, "")) { + if (frame + 1 < doc.frames.size()) setFrame(frame + 1); + else insertFrame(false); + } + if (imagePill("ui/icons/SkipLast.png", 1169, 645 + downOffset, 58, 48, false, frame + 1 < doc.frames.size(), "")) + setFrame(doc.frames.size() - 1, true, "frame_last"); + if (canvasMenuProgress > 0.0f) { + const int menuX = 154; + const int menuY = 635 + downOffset + static_cast(std::lround((1.0f - canvasMenuProgress) * 95.0f)); + drawImage("ui/icons/CanvasMenu_PopoutBg.png", menuX, menuY - 9, 310, 80); + auto menuIconButton = [&](const char* icon, int x) { + drawTexture(whiteImage(icon), x + 4, menuY + 5, 50, 50); + return hit(x, menuY + 1, 58, 48); + }; + if (menuIconButton("ui/icons/CanvasMenu_MusicToggle.png", menuX + 47)) { + clickConsumed = true; + canvasMenuOpen = false; + stopPlayback(); + audioTrack = 0; + audioEraseArmedTrack = -1; + scanImports(); + requestAudioUploadServer(); + modal = Modal::Audio; + modalTransitionStart = SDL_GetTicks(); + } else if (menuIconButton("ui/icons/CanvasMenu_FrameToggle.png", menuX + 126)) { + canvasMenuOpen = false; + screen = Screen::Sequence; + clickConsumed = true; + } else if (menuIconButton("ui/icons/Camera.svg", menuX + 205)) { + canvasMenuOpen = false; + openPhotoCapture(); + clickConsumed = true; + } + } + if (paintMenu) { + rounded(845, 88, 342, 270, 14, SDL_Color{0, 0, 0, 45}); + rounded(842, 82, 342, 270, 14, WHITE); + rounded(846, 86, 334, 262, 11, LIGHT); + const auto inkPreview = ink.a ? SDL_Color{ink.r, ink.g, ink.b, 255} : DRAW_BLACK; + rounded(1144, 91, 26, 26, 13, inkPreview); + if (activeTool == Tool::Pen) { + text("Pen size", 858, 89, 17, INK, true); + const std::array brushSizes{{1, 2, 4, 8, 16, 32}}; + int selectedBrush = 0; + for (int i = 1; i < static_cast(brushSizes.size()); ++i) + if (std::abs(brushSizes[i] - brush) < std::abs(brushSizes[selectedBrush] - brush)) selectedBrush = i; + for (int i = 0; i < 6; ++i) { + const int bx = 866 + i * 51; + if (i == selectedBrush && !square) rounded(bx - 3, 109, 37, 37, 7, ORANGE); + if (i == selectedBrush && square) rounded(bx - 3, 158, 37, 37, 7, ORANGE); + const int markSize = std::clamp(brushSizes[i], 4, 29); + const int markOffset = (31 - markSize) / 2; + rounded(bx + markOffset, 112 + markOffset, markSize, markSize, markSize / 2, SDL_Color{28, 30, 35, 255}); + rounded(bx + markOffset, 161 + markOffset, markSize, markSize, 2, SDL_Color{28, 30, 35, 255}); + if (hit(bx, 112, 31, 31) || hit(bx, 161, 31, 31)) { + brush = brushSizes[i]; square = pointerY > 150; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on"); + } + } + static const std::array penNames{{ + "1 px", "Dir 2 px", "3x3 square", "Squiggle", + "Directional =", "Sparse air", "Jitter line", "Dense spray", + "5x5 square", "7x7 square", "9x9 square", "Dynamic width", + "Hollow diamond", "Large air", "Wide jitter", "Heavy spray" + }}; + for (int i = 0; i < static_cast(penNames.size()); ++i) { + const int x = 855 + (i % 4) * 80; + const int y = 203 + (i / 4) * 20; + rounded(x, y, 77, 18, 5, penType == i ? ORANGE : WHITE); + centered(penNames[static_cast(i)], x + 1, y, 75, 18, 11, penType == i ? WHITE : INK); + if (hit(x, y, 77, 18)) { + penType = i; + squiggleAnchorX = squiggleAnchorY = ringPreviousX = ringPreviousY = -1; + clickConsumed = true; + playEvent("tool_pen", "draw_tool_btn_on"); + } + } + if (pill("−", 1093, 291, 38, 32, false, 20)) brush = std::max(1, brush - 1); + if (pill("+", 1134, 291, 38, 32, false, 20)) brush = std::min(32, brush + 1); + rounded(864, 287, 32, 32, 16, inkPreview); + } else if (activeTool == Tool::Brush) { + text("Brush patterns", 858, 89, 17, INK, true); + for (int i = 0; i < 8; ++i) { + const int x = 859 + (i % 4) * 78; + const int y = 124 + (i / 4) * 48; + rounded(x - 2, y - 2, 70, 42, 6, pattern == i ? ORANGE : WHITE); + rounded(x, y, 66, 38, 4, SDL_Color{255, 255, 255, 255}); + SDL_Rect clip{x, y, 66, 38}; + SDL_RenderSetClipRect(renderer, &clip); + for (int py = 0; py < 38; ++py) { + for (int px = 0; px < 66; ++px) { + if (switchnoteBrushPattern(i, px / 2, py / 2)) fill(x + px, y + py, 1, 1, inkPreview); + } + } + SDL_RenderSetClipRect(renderer, nullptr); + if (hit(x - 2, y - 2, 70, 42)) { + pattern = i; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on"); + } + } + rounded(859, 227, 183, 39, 11, advancedBrush ? CREAM : WHITE); + text(advancedBrush ? "Advanced brush ON" : "Advanced brush OFF", 873, 237, 16, + advancedBrush ? ORANGE : INK, true); + if (hit(859, 227, 183, 39)) { + advancedBrush = !advancedBrush; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on"); + } + } else { + text("Erase tools", 858, 89, 17, INK, true); + const std::array eraseLabels{{"Pixel", "3×3", "Round M", "Round L", "9×9", "15×15", "Vertical", "Horizontal"}}; + for (int i = 0; i < 8; ++i) { + const int x = 854 + (i % 4) * 81; + const int y = 118 + (i / 4) * 83; + rounded(x, y, 72, 73, 9, eraserSlot == i ? CREAM : WHITE); + const int centerX = x + 36, centerY = y + 28; + for (int dy = -7; dy <= 7; ++dy) for (int dx = -7; dx <= 7; ++dx) + if (switchnoteEraserContains(i, dx, dy)) fill(centerX + dx * 2 - 1, centerY + dy * 2 - 1, 2, 2, INK); + centered(eraseLabels[static_cast(i)], x + 2, y + 51, 68, 17, 12, INK); + if (hit(x, y, 72, 73)) { + eraserSlot = i; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on"); + } + } + } + } + if (colorMenu) { + rounded(845, 88, 342, 162, 14, SDL_Color{0, 0, 0, 45}); + rounded(842, 82, 342, 162, 14, WHITE); + rounded(846, 86, 334, 154, 11, LIGHT); + text("Ink color", 858, 89, 17, INK, true); + for (int i = 0; i < static_cast(kInkPresetIndexes.size()); ++i) { + const int index = kInkPresetIndexes[static_cast(i)]; + const int x = 875 + (i % 5) * 57, y = 126 + (i / 5) * 54; + const auto c = doc.palette[static_cast(index)]; + if (index == color) rounded(x - 3, y - 3, 42, 42, 9, ORANGE); + rounded(x, y, 36, 36, 18, SDL_Color{c.r, c.g, c.b, 255}); + if (hit(x - 3, y - 3, 42, 42)) { + color = index; + colorMenu = false; + clickConsumed = true; + playEvent("tool_color", "draw_tool_btn_on"); + } + } + } + if (toolMenu) { + rounded(845, 88, 342, 154, 14, SDL_Color{0, 0, 0, 45}); + rounded(842, 82, 342, 154, 14, WHITE); + rounded(846, 86, 334, 146, 11, LIGHT); + text("Tools", 858, 89, 17, INK, true); + const std::array tools{{Tool::Pen, Tool::Brush, Tool::Eraser}}; + const std::array labels{{"Pen", "Brush", "Eraser"}}; + for (int i = 0; i < 3; ++i) { + const int x = 856 + i * 106; + const int y = 121; + rounded(x - 2, y - 2, 96, 78, 12, activeTool == tools[i] ? CREAM : WHITE); + drawToolIcon(tools[i], x + 20, y + 1, 48); + centered(labels[i], x, y + 47, 92, 24, 15, activeTool == tools[i] ? ORANGE : INK); + if (hit(x - 2, y - 2, 96, 78)) { + activeTool = tools[i]; + toolMenu = false; + clickConsumed = true; + playEvent("tool_pen", "draw_tool_btn_on"); + } + } + } +} + +void App::renderViewer() { + if (!docLoaded) { screen = Screen::Library; return; } + const float scale = std::min(W / static_cast(sppm::kWidth), H / static_cast(sppm::kHeight)); + const int cw = static_cast(sppm::kWidth * scale); + const int ch = static_cast(sppm::kHeight * scale); + const int cx = (W - cw) / 2 + panX; + const int cy = (H - ch) / 2 + panY; + fill(0, 0, W, H, SDL_Color{255, 255, 255, 255}); + drawCurrentFrame(cx, cy, cw, ch, false); + if (pill("Back", 20, 18, 104, 46)) { + stopPlayback(); + screen = galleryViewing ? Screen::GalleryDetails : Screen::Details; + } + if (galleryViewing && pill("Comments", 1080, 18, 170, 46, true)) openGalleryComments(); + if (imagePill("ui/icons/SkipFirst.png", 913, 645, 55, 48, false, frame > 0, "")) setFrame(0, true, "frame_first"); + if (imagePill("ui/icons/SkipPrev.png", 974, 645, 55, 48, false, frame > 0, "")) setFrame(frame > 5 ? frame - 5 : 0, true, "frame_previous"); + playControl(1044, 633, 70, 66); + if (imagePill("ui/icons/SkipNext.png", 1116, 645, 55, 48, false, frame + 1 < doc.frames.size(), "")) setFrame(frame + 1, true, "frame_next"); + if (imagePill("ui/icons/SkipLast.png", 1177, 645, 58, 48, false, frame + 1 < doc.frames.size(), "")) setFrame(doc.frames.size() - 1, true, "frame_last"); + rounded(1114, 584, 142, 40, 20, WHITE); + centered(std::to_string(frame + 1) + " / " + std::to_string(doc.frames.size()), 1114, 584, 142, 40, 18, ORANGE, true); +} + +void App::renderComments() { + background(); + if (gallery.empty()) { if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65)) screen = Screen::Gallery; return; } + const Item& item = gallery[gallerySelected % gallery.size()]; + if (imageButton("ui/BackButton_Gallery.png", 39, 35, 65, 65)) screen = Screen::GalleryDetails; + text("Comments", 126, 39, 32, INK, true); + text(utf8Prefix(item.title, 44), 126, 79, 19, GRAY); + if (!galleryCommentsLoaded && galleryComments.empty()) centered("Connecting to Gallery…", 350, 245, 580, 45, 20, GRAY); + else if (galleryComments.empty()) centered("No comments yet.", 350, 245, 580, 45, 24, INK); + else { + constexpr int viewportTop = 130; + constexpr int viewportHeight = 490; + auto commentHasInk = [](const GalleryComment& comment) { + return comment.drawing.size() == sppm::kPixelsPerLayer && + std::any_of(comment.drawing.begin(), comment.drawing.end(), [](std::uint8_t pixel) { return pixel != 0; }); + }; + std::vector> wrappedComments(galleryComments.size()); + std::vector heights; + heights.reserve(galleryComments.size()); + int contentHeight = 0; + TTF_Font* commentFont = font(19, false); + for (std::size_t index = 0; index < galleryComments.size(); ++index) { + const auto& comment = galleryComments[index]; + const bool hasDrawing = commentHasInk(comment); + if (!comment.text.empty() && commentFont) { + const std::string display = brandString(comment.text); + std::string line, word; + std::istringstream words(display); + while (words >> word) { + int wordWidth = 0, wordHeight = 0; + TTF_SizeUTF8(commentFont, word.c_str(), &wordWidth, &wordHeight); + if (wordWidth > 620) { + if (!line.empty()) { wrappedComments[index].push_back(line); line.clear(); } + std::string fragment; + for (std::size_t offset = 0; offset < word.size();) { + const unsigned char lead = static_cast(word[offset]); + const std::size_t count = lead < 0x80 ? 1 : (lead & 0xE0) == 0xC0 ? 2 : (lead & 0xF0) == 0xE0 ? 3 : 4; + const std::string glyph = word.substr(offset, count); + const std::string candidate = fragment + glyph; + int width = 0, height = 0; + TTF_SizeUTF8(commentFont, candidate.c_str(), &width, &height); + if (!fragment.empty() && width > 620) { wrappedComments[index].push_back(fragment); fragment = glyph; } + else fragment = candidate; + offset += count; + } + line = fragment; + continue; + } + const std::string candidate = line.empty() ? word : line + " " + word; + int measured = 0, measuredHeight = 0; + TTF_SizeUTF8(commentFont, candidate.c_str(), &measured, &measuredHeight); + if (!line.empty() && measured > 620) { + wrappedComments[index].push_back(line); + line = word; + } else line = candidate; + } + if (!line.empty()) wrappedComments[index].push_back(line); + } + const int lineCount = static_cast(wrappedComments[index].size()); + const int textBottom = lineCount ? 43 + lineCount * 23 : 0; + const int drawingTop = std::max(88, textBottom + 6); + const int height = hasDrawing ? drawingTop + 332 + 10 : std::max(88, textBottom + 12); + heights.push_back(height); + contentHeight += height + 12; + } + const int maxScroll = std::max(0, contentHeight - viewportHeight); + galleryCommentScroll = std::clamp(galleryCommentScroll, 0, maxScroll); + SDL_Rect clip{197, viewportTop, 862, viewportHeight}; + SDL_RenderSetClipRect(renderer, &clip); + int virtualY = viewportTop + 7 - galleryCommentScroll; + for (std::size_t index = 0; index < galleryComments.size(); ++index) { + const auto& comment = galleryComments[index]; + const int cardHeight = heights[index]; + const int y = virtualY; + if (y + cardHeight >= viewportTop && y <= viewportTop + viewportHeight) { + rounded(318, y + 5, 652, cardHeight, 13, SDL_Color{0, 0, 0, 35}); + rounded(318, y, 652, cardHeight, 13, WHITE); + const bool hasDrawing = commentHasInk(comment); + const int avatarSize = hasDrawing ? 60 : 52; + rounded(208, y + 8, avatarSize, avatarSize, 10, LIGHT); + centered(comment.author.empty() ? "?" : utf8Prefix(comment.author, 1), + 208, y + 8, avatarSize, avatarSize, hasDrawing ? 27 : 23, INK, true); + text(utf8Prefix(comment.author, 25), 284, y + 11, 21, INK, true); + text(comment.created, 784, y + 13, 17, GRAY); + for (std::size_t line = 0; line < wrappedComments[index].size(); ++line) + text(wrappedComments[index][line], 284, y + 43 + static_cast(line) * 23, 19, INK); + if (hasDrawing) if (SDL_Texture* drawing = galleryCommentTexture(comment, index)) { + const int drawingY = y + std::max(88, (wrappedComments[index].empty() ? 0 : 43 + static_cast(wrappedComments[index].size()) * 23) + 6); + // SPPM comment ink is 640x360. Keep its 16:9 drawing area intact. + drawTexture(drawing, 340, drawingY, 590, 332); + } + } + virtualY += cardHeight + 12; + } + SDL_RenderSetClipRect(renderer, nullptr); + if (maxScroll > 0) { + fill(1205, 146, 6, 450, SDL_Color{255, 190, 218, 255}); + const int thumbHeight = std::max(54, viewportHeight * viewportHeight / contentHeight); + const int thumbY = 146 + ((viewportHeight - thumbHeight) * galleryCommentScroll / maxScroll); + fill(1205, thumbY, 6, thumbHeight, GREEN); + } + } + if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65)) screen = Screen::GalleryDetails; + if (pill("Post Comment Y", 1022, 633, 218, 47, true, 20)) screen = Screen::CommentCanvas; +} + +void App::renderCommentCanvas() { + background(); + if (!commentTexture || commentDirty) { + if (commentTexture) SDL_DestroyTexture(commentTexture); + SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, sppm::kWidth, sppm::kHeight, 32, SDL_PIXELFORMAT_RGBA32); + if (surface) { + auto* rgba = static_cast(surface->pixels); + for (std::size_t i = 0; i < sppm::kPixelsPerLayer; ++i) rgba[i] = commentPixels[i] ? SDL_MapRGBA(surface->format, 20, 22, 28, 255) : SDL_MapRGBA(surface->format, 0, 0, 0, 0); + commentTexture = SDL_CreateTextureFromSurface(renderer, surface); + if (commentTexture) SDL_SetTextureScaleMode(commentTexture, SDL_ScaleModeNearest); + SDL_FreeSurface(surface); + } + commentDirty = false; + } + fill(0, 0, W, H, WHITE); + if (commentTexture) drawTexture(commentTexture, 0, 0, W, H); + if (pill("Text", 20, 20, 115, 44, true, 18)) beginText(Field::CommentText, ""); + if (!commentDraft.empty()) text(utf8Prefix(commentDraft, 72), 158, 27, 19, INK); + if (pill("Cancel", 22, 644, 134, 45, true, 20)) screen = Screen::GalleryComments; + if (pill("Clear X", 1003, 644, 117, 45, true, 20)) { commentPixels.assign(sppm::kPixelsPerLayer, 0); commentDirty = true; } + if (pill("Send Y", 1142, 644, 116, 45, true, 20)) postGalleryComment(); +} + +void App::beginText(Field field, const std::string& current) { +#ifdef __SWITCH__ + SwkbdConfig keyboard; + if (R_SUCCEEDED(swkbdCreate(&keyboard, 0))) { + swkbdConfigMakePresetDefault(&keyboard); + swkbdConfigSetInitialText(&keyboard, current.c_str()); + const char* header = field == Field::Title ? "Switchnote title" : field == Field::Description ? "Description" : + field == Field::FolderName ? "New folder" : field == Field::GalleryBio ? "Gallery bio" : "Comment"; + swkbdConfigSetHeaderText(&keyboard, header); + char buffer[257]{}; + if (R_SUCCEEDED(swkbdShow(&keyboard, buffer, sizeof(buffer)))) { + editingField = field; + inputText = buffer; + applyText(); + } + swkbdClose(&keyboard); + } +#else + editingField = field; + inputText = current; + modal = Modal::TextInput; + SDL_StartTextInput(); +#endif +} + +void App::applyText() { + SDL_StopTextInput(); + const std::string value = inputText.substr(0, editingField == Field::GalleryBio ? 200 : 240); + switch (editingField) { + case Field::Title: + if (docLoaded && !value.empty()) { doc.metadata.title = value; markDirty(); } + break; + case Field::Description: + if (docLoaded) { doc.metadata.description = value; markDirty(); } + break; + case Field::FolderName: { + std::string safe; + for (unsigned char ch : value) if (std::isalnum(ch) || ch == ' ' || ch == '_' || ch == '-') safe.push_back(static_cast(ch)); + if (!safe.empty()) { + ensureDir(join(notesRoot, safe)); + folder = safe; + selected = 0; + scanLibrary(); + showToast("Folder created"); + } + break; + } + case Field::CommentText: + if (!value.empty()) { commentDraft = value; screen = Screen::CommentCanvas; } + break; + case Field::GalleryBio: + if (galleryProfileIsOwn) { galleryProfile.bio = value; saveGalleryProfile(); } + break; + case Field::None: break; + } + inputText.clear(); + editingField = Field::None; + modal = Modal::None; +} + +bool App::importAudioFile(const std::string& path, int targetTrack, const std::string& displayName) { + if (!docLoaded || targetTrack < 0 || targetTrack >= 4) return false; + SDL_AudioSpec source{}; + Uint8* raw = nullptr; + Uint32 rawLength = 0; + if (!SDL_LoadWAV(path.c_str(), &source, &raw, &rawLength)) { + showToast("Could not read WAV: " + std::string(SDL_GetError())); + return false; + } + const int bytesPerSample = SDL_AUDIO_BITSIZE(source.format) / 8; + if (source.freq <= 0 || source.channels < 1 || source.channels > 2 || bytesPerSample <= 0 || + rawLength == 0 || rawLength > static_cast(std::numeric_limits::max())) { + SDL_FreeWAV(raw); + showToast("Use a valid mono or stereo WAV file"); + return false; + } + const std::size_t bytesPerFrame = static_cast(source.channels) * bytesPerSample; + const std::size_t availableFrames = rawLength / bytesPerFrame; + const int maximumSeconds = targetTrack == 0 ? 60 : 2; + const std::size_t maximumFrames = static_cast(maximumSeconds) * static_cast(source.freq); + const std::size_t framesToKeep = std::min(availableFrames, maximumFrames); + if (!framesToKeep) { + SDL_FreeWAV(raw); + showToast("This audio file is empty"); + return false; + } + const bool trimmed = framesToKeep < availableFrames; + SDL_AudioCVT cvt{}; + const int build = SDL_BuildAudioCVT(&cvt, source.format, source.channels, source.freq, AUDIO_S16SYS, 2, 48000); + if (build < 0) { SDL_FreeWAV(raw); showToast("WAV format is not supported"); return false; } + cvt.len = static_cast(framesToKeep * bytesPerFrame); + cvt.buf = static_cast(SDL_malloc(static_cast(cvt.len) * cvt.len_mult)); + if (!cvt.buf) { SDL_FreeWAV(raw); showToast("Not enough memory to import audio"); return false; } + std::memcpy(cvt.buf, raw, static_cast(cvt.len)); + SDL_FreeWAV(raw); + if (cvt.needed && SDL_ConvertAudio(&cvt) != 0) { SDL_free(cvt.buf); showToast("Could not convert WAV audio"); return false; } + if (!cvt.needed) cvt.len_cvt = cvt.len; + const std::size_t sampleCount = static_cast(cvt.len_cvt) / sizeof(std::int16_t); + constexpr std::size_t maxAudioSamples = 32ULL * 1024 * 1024; + std::size_t otherSamples = 0; + for (std::size_t i = 0; i < doc.audioTracks.size(); ++i) + if (i != static_cast(targetTrack)) otherSamples += doc.audioTracks[i].pcm.size(); + if (!cvt.len_cvt || sampleCount > maxAudioSamples || otherSamples > maxAudioSamples - sampleCount) { + SDL_free(cvt.buf); + showToast("There is not enough Switchnote space for this audio"); + return false; + } + while (doc.audioTracks.size() <= static_cast(targetTrack)) { + sppm::AudioTrack blank; + blank.name = doc.audioTracks.empty() ? "BGM" : "SE" + std::to_string(doc.audioTracks.size()); + blank.sampleRate = 48000; blank.channels = 2; + doc.audioTracks.push_back(std::move(blank)); + } + auto& track = doc.audioTracks[static_cast(targetTrack)]; + track.name = targetTrack == 0 ? "BGM" : "SE" + std::to_string(targetTrack); + track.sampleRate = 48000; track.channels = 2; + track.referenceFps = 0; + track.referenceSpeed = targetTrack == 0 ? doc.metadata.speed : 0; + track.pcm.resize(sampleCount); + std::memcpy(track.pcm.data(), cvt.buf, sampleCount * sizeof(std::int16_t)); + SDL_free(cvt.buf); + if (targetTrack > 0 && track.frameTriggers.empty()) track.frameTriggers = {static_cast(frame)}; + markDirty(); + rebuildAudio(); + if (!saveDocument(true)) return false; + showToast("Imported " + displayName + " to " + track.name + (trimmed ? " (trimmed)" : "")); + return true; +} + +void App::importAudio() { + if (!docLoaded || importFiles.empty()) { showToast("Add WAV files to the sdmc:/switch/Switchnote/import folder"); return; } + const std::string filename = importFiles[importSelection % importFiles.size()]; + importAudioFile(join(importRoot, filename), audioTrack, filename); +} + +void App::exportDocument(int destination) { + if (!docLoaded) return; + std::string base = doc.metadata.title.empty() ? "Switchnote" : doc.metadata.title; + for (char& ch : base) if (!std::isalnum(static_cast(ch)) && ch != '-' && ch != '_') ch = '_'; + std::string error; + if (destination == 0) { + const std::string directory = "sdmc:/Switchnotes"; + ensureDir(directory); + const std::string path = join(directory, base + ".mp4"); + const bool ok = sppm::exportMp4(doc, path, error); + showToast(ok ? "Silent MP4 exported to " + path : "Export failed: " + error); + return; + } +#ifdef __SWITCH__ + std::vector image(static_cast(1280) * 720 * 4, 255); + const auto& cover = doc.frames[doc.metadata.coverFrame < doc.frames.size() ? doc.metadata.coverFrame : 0]; + for (std::size_t y = 0; y < 720; ++y) { + for (std::size_t x = 0; x < 1280; ++x) { + const std::size_t pixel = (y / 2) * sppm::kWidth + x / 2; + unsigned red = 255, green = 255, blue = 255; + for (std::size_t layerIndex = cover.layers.size(); layerIndex-- > 0;) { + const std::uint8_t index = cover.layers[layerIndex][pixel]; + if (!index) continue; + const auto& color = doc.palette[index]; + const unsigned alpha = color.a; + red = (color.r * alpha + red * (255 - alpha) + 127) / 255; + green = (color.g * alpha + green * (255 - alpha) + 127) / 255; + blue = (color.b * alpha + blue * (255 - alpha) + 127) / 255; + } + const std::size_t offset = (y * 1280 + x) * 4; + image[offset] = static_cast(red); + image[offset + 1] = static_cast(green); + image[offset + 2] = static_cast(blue); + image[offset + 3] = 255; + } + } + Result initResult = capssuInitialize(); + if (R_FAILED(initResult)) { + showToast("Could not open System Captures (" + std::to_string(initResult) + ")"); + return; + } + CapsApplicationAlbumEntry entry{}; + const Result saveResult = capssuSaveScreenShot(image.data(), image.size(), + AlbumReportOption_Enable, AlbumImageOrientation_Unknown0, &entry); + capssuExit(); + showToast(R_SUCCEEDED(saveResult) ? "Cover saved to System Captures" : + "System Captures export failed (" + std::to_string(saveResult) + ")"); +#else + const std::string path = join(exportRoot, base + ".png"); + const bool ok = sppm::exportPngCover(doc, path, error); + showToast(ok ? "System Captures export is Switch-only; saved cover to " + path : "Export failed: " + error); +#endif +} + +void App::openPhotoCapture() { + if (!docLoaded || frame >= doc.frames.size()) return; + stopPlayback(false); +#ifdef __SWITCH__ + if (photoProbeRunning) return; + irPhotoTaken = false; + irRotatePhoto = false; + irImageReady = false; + irImageState = {}; + photoProbeRunning = true; + photoProbeHasCamera = false; + photoProbeFinished.store(false, std::memory_order_relaxed); + beginLoading(); + modal = Modal::PhotoProbe; + modalTransitionStart = SDL_GetTicks(); + if (R_FAILED(threadCreate(&photoProbeThread, photoProbeThreadEntry, this, nullptr, 0x10000, 0x3B, -2))) { + photoProbeRunning = false; + endLoading(); + modal = Modal::None; + openPhotoUpload(); + return; + } + photoProbeThreadCreated = true; + if (R_FAILED(threadStart(&photoProbeThread))) { + threadClose(&photoProbeThread); + photoProbeThreadCreated = false; + photoProbeRunning = false; + endLoading(); + modal = Modal::None; + openPhotoUpload(); + } +#else + openPhotoUpload(); +#endif +} + +#ifdef __SWITCH__ +void App::photoProbeThreadEntry(void* argument) { + auto* app = static_cast(argument); + app->runPhotoProbeThread(); + threadExit(); +} + +void App::runPhotoProbeThread() { + irCameraRunning = false; + irsReady = irsInitialize() == 0; + if (irsReady) { + // IR probing can take several service round-trips on consoles with + // detached/reassigned Joy-Cons. Keep all of it off the render thread. + const bool handheld = (hidGetNpadStyleSet(HidNpadIdType_Handheld) & HidNpadStyleTag_NpadHandheld) != 0; + const std::array cameraCandidates = handheld + ? std::array{HidNpadIdType_Handheld, HidNpadIdType_No1, HidNpadIdType_No2, + HidNpadIdType_No3, HidNpadIdType_No4, HidNpadIdType_No5, HidNpadIdType_No6, HidNpadIdType_No7, HidNpadIdType_No8} + : std::array{HidNpadIdType_No1, HidNpadIdType_No2, HidNpadIdType_No3, + HidNpadIdType_No4, HidNpadIdType_No5, HidNpadIdType_No6, HidNpadIdType_No7, HidNpadIdType_No8, HidNpadIdType_Handheld}; + IrsImageTransferProcessorConfig config{}; + irsGetDefaultImageTransferProcessorConfig(&config); + config.format = IrsImageTransferProcessorFormat_320x240; + for (const HidNpadIdType id : cameraCandidates) { + IrsIrCameraHandle candidate{}; + if (R_FAILED(irsGetIrCameraHandle(&candidate, id))) continue; + IrsIrCameraStatus status{}; + if (R_FAILED(irsGetIrCameraStatus(candidate, &status)) || status != IrsIrCameraStatus_Available) continue; + if (R_FAILED(irsRunImageTransferProcessor(candidate, &config, 0x100000))) continue; + irCamera = candidate; + irCameraRunning = true; + break; + } + if (!irCameraRunning) for (const HidNpadIdType id : cameraCandidates) { + IrsIrCameraHandle candidate{}; + if (R_FAILED(irsGetIrCameraHandle(&candidate, id))) continue; + if (R_FAILED(irsRunImageTransferProcessor(candidate, &config, 0x100000))) continue; + irCamera = candidate; + irCameraRunning = true; + break; + } + } + if (!irCameraRunning && irsReady) { + irsExit(); + irsReady = false; + } + photoProbeHasCamera = irCameraRunning; + photoProbeFinished.store(true, std::memory_order_release); +} +#endif + +void App::consumePhotoProbe() { +#ifdef __SWITCH__ + if (!photoProbeRunning || !photoProbeFinished.exchange(false, std::memory_order_acquire)) return; + if (photoProbeThreadCreated) { + threadWaitForExit(&photoProbeThread); + threadClose(&photoProbeThread); + photoProbeThreadCreated = false; + } + photoProbeRunning = false; + endLoading(); + if (photoProbeHasCamera) { + if (!irPreviewTexture && renderer) + irPreviewTexture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA32, SDL_TEXTUREACCESS_STREAMING, 320, 240); + modal = Modal::PhotoChoice; + modalTransitionStart = SDL_GetTicks(); + } else { + modal = Modal::None; + openPhotoUpload(); + } +#endif +} + +void App::openPhotoUpload() { + stopPlayback(false); + modal = Modal::PhotoUpload; + modalTransitionStart = SDL_GetTicks(); + requestAudioUploadServer(true); +} + +void App::openPhotoAlbum() { +#ifdef __SWITCH__ + for (SDL_Texture*& texture : photoAlbumThumbs) { + if (texture) SDL_DestroyTexture(texture); + texture = nullptr; + } + photoAlbumEntries.clear(); + photoAlbumPage = 0; + photoAlbumThumbIndexes.fill(std::numeric_limits::max()); + if (R_FAILED(capsaInitialize())) { showToast("Could not open the Switch photo album."); return; } + u64 count = 0; + Result result = capsaGetAlbumFileCount(CapsAlbumStorage_Sd, &count); + if (R_FAILED(result) || count == 0) result = capsaGetAlbumFileCount(CapsAlbumStorage_Nand, &count); + if (R_FAILED(result) || count == 0) { + capsaExit(); + showToast(count == 0 ? "No photos were found in the album." : "Could not read the Switch photo album."); + return; + } + count = std::min(count, 2048); + std::vector entries(static_cast(count)); + u64 written = 0; + result = capsaGetAlbumFileList(CapsAlbumStorage_Sd, &written, entries.data(), entries.size()); + if (R_FAILED(result) || written == 0) result = capsaGetAlbumFileList(CapsAlbumStorage_Nand, &written, entries.data(), entries.size()); + capsaExit(); + if (R_FAILED(result) || written == 0) { showToast("Could not read photos from the Switch album."); return; } + entries.resize(static_cast(std::min(written, entries.size()))); + for (const auto& entry : entries) if (entry.file_id.content == CapsContentType_Screenshot) photoAlbumEntries.push_back(entry); + std::sort(photoAlbumEntries.begin(), photoAlbumEntries.end(), [](const CapsAlbumEntry& a, const CapsAlbumEntry& b) { + const auto& x = a.file_id.datetime; const auto& y = b.file_id.datetime; + if (x.year != y.year) return x.year > y.year; + if (x.month != y.month) return x.month > y.month; + if (x.day != y.day) return x.day > y.day; + if (x.hour != y.hour) return x.hour > y.hour; + if (x.minute != y.minute) return x.minute > y.minute; + if (x.second != y.second) return x.second > y.second; + return x.id > y.id; + }); + if (photoAlbumEntries.empty()) { showToast("No screenshots were found in the Switch photo album."); return; } + modal = Modal::PhotoAlbum; + modalTransitionStart = SDL_GetTicks(); +#else + showToast("The Switch photo album is only available on Nintendo Switch."); +#endif +} + +void App::importPhotoAlbumEntry(std::size_t index) { +#ifdef __SWITCH__ + if (!docLoaded || frame >= doc.frames.size() || index >= photoAlbumEntries.size()) return; + if (R_FAILED(capsaInitialize())) { showToast("Could not open the Switch photo album."); return; } + const CapsAlbumEntry& entry = photoAlbumEntries[index]; + if (entry.size == 0 || entry.size > 32U * 1024U * 1024U) { + capsaExit(); showToast("That album image is too large to import."); return; + } + std::vector encoded(static_cast(entry.size)); + u64 loaded = 0; + const Result result = capsaLoadAlbumFile(&entry.file_id, &loaded, encoded.data(), encoded.size()); + capsaExit(); + if (R_FAILED(result) || loaded == 0 || loaded > encoded.size()) { showToast("Could not load that album image."); return; } + SDL_RWops* stream = SDL_RWFromConstMem(encoded.data(), static_cast(loaded)); + SDL_Surface* decoded = stream ? IMG_Load_RW(stream, 1) : nullptr; + if (!decoded || decoded->w <= 0 || decoded->h <= 0 || decoded->w > 8192 || decoded->h > 8192) { + if (decoded) SDL_FreeSurface(decoded); + showToast("That album image could not be decoded."); return; + } + SDL_Surface* rgba = SDL_ConvertSurfaceFormat(decoded, SDL_PIXELFORMAT_RGBA32, 0); + SDL_FreeSurface(decoded); + if (!rgba) { showToast("Could not prepare the album image."); return; } + photoImportRgba.assign(600U * sppm::kHeight * 4U, 0); + const float scale = std::min(600.0f / rgba->w, static_cast(sppm::kHeight) / rgba->h); + const int drawW = std::max(1, static_cast(std::lround(rgba->w * scale))); + const int drawH = std::max(1, static_cast(std::lround(rgba->h * scale))); + const int left = (600 - drawW) / 2, top = (static_cast(sppm::kHeight) - drawH) / 2; + if (SDL_MUSTLOCK(rgba)) SDL_LockSurface(rgba); + for (int y = 0; y < drawH; ++y) { + const int sy = std::min(rgba->h - 1, y * rgba->h / drawH); + const auto* row = static_cast(rgba->pixels) + static_cast(sy) * rgba->pitch; + for (int x = 0; x < drawW; ++x) { + const int sx = std::min(rgba->w - 1, x * rgba->w / drawW); + const auto* src = row + static_cast(sx) * 4U; + auto* dst = photoImportRgba.data() + (static_cast(top + y) * 600U + left + x) * 4U; + std::memcpy(dst, src, 4U); + } + } + if (SDL_MUSTLOCK(rgba)) SDL_UnlockSurface(rgba); + SDL_FreeSurface(rgba); + photoImportLayer = std::clamp(layer, 0, 3); + photoImportRow = 0; + pushUndo(); + std::fill(doc.frames[frame].layers[static_cast(photoImportLayer)].begin(), + doc.frames[frame].layers[static_cast(photoImportLayer)].end(), 0); + photoImporting = true; + modal = Modal::None; + beginLoading(); +#else + (void)index; +#endif +} + +void App::closePhotoCapture() { +#ifdef __SWITCH__ + if (irCameraRunning) { + irsStopImageProcessor(irCamera); + irCameraRunning = false; + } + if (irsReady) { + irsExit(); + irsReady = false; + } +#endif + if (photoTransferActive) stopAudioUploadServer(); + modal = Modal::None; +} + +void App::updatePhotoCapture() { +#ifdef __SWITCH__ + if (!irsReady || !irCameraRunning || irPhotoTaken) return; + const std::uint64_t previousSample = irImageState.sampling_number; + if (R_FAILED(irsGetImageTransferProcessorState(irCamera, irImage.data(), irImage.size(), &irImageState))) return; + if (irImageReady && irImageState.sampling_number == previousSample) return; + irImageReady = true; + for (std::size_t i = 0; i < irImage.size(); ++i) { + irPreviewRgba[i * 4] = irImage[i]; + irPreviewRgba[i * 4 + 1] = irImage[i]; + irPreviewRgba[i * 4 + 2] = irImage[i]; + } + if (irPreviewTexture) SDL_UpdateTexture(irPreviewTexture, nullptr, irPreviewRgba.data(), 320 * 4); +#endif +} + +void App::importPhoto() { +#ifdef __SWITCH__ + if (!docLoaded || !irPhotoTaken || frame >= doc.frames.size()) return; + pushUndo(); + auto& pixels = doc.frames[frame].layers[static_cast(layer)]; + std::fill(pixels.begin(), pixels.end(), 0); + // Keep the IR image's 4:3 proportions, centered on the 16:9 Switchnote + // canvas. Its native 320x240 pixels are doubled without interpolation. + const int targetW = irRotatePhoto ? 270 : 480; + constexpr int targetH = 360; + const int left = (sppm::kWidth - targetW) / 2; + const auto palette = doc.palette; + std::array grayToPalette{}; + for (int shade = 0; shade < 256; ++shade) { + int bestError = std::numeric_limits::max(); + std::uint8_t bestIndex = 1; + for (int index = 16; index < 256; ++index) { + const auto& c = palette[static_cast(index)]; + const int luminance = (c.r * 299 + c.g * 587 + c.b * 114) / 1000; + const int error = std::abs(luminance - shade); + if (error < bestError) { bestError = error; bestIndex = static_cast(index); } + } + grayToPalette[static_cast(shade)] = bestIndex; + } + for (int y = 0; y < targetH; ++y) { + for (int x = 0; x < targetW; ++x) { + const int rotatedX = irRotatePhoto ? x * 240 / targetW : 0; + const int rotatedY = irRotatePhoto ? y * 320 / targetH : 0; + const int sourceX = irRotatePhoto ? rotatedY : x / 1.5; + const int sourceY = irRotatePhoto ? 239 - rotatedX : y / 1.5; + const auto gray = irPhoto[static_cast(sourceY) * 320 + sourceX]; + pixels[static_cast(y) * sppm::kWidth + left + x] = grayToPalette[gray]; + } + } + markDirty(); + invalidateFrames(); + showToast("Photo imported to the selected layer"); +#endif + closePhotoCapture(); +} + +bool App::importUploadedPhoto() { + if (photoImporting) return false; + if (!docLoaded || frame >= doc.frames.size()) return false; + const std::string imagePath = join(importRoot, "photo-transfer.rgba"); + const std::string optionsPath = join(importRoot, "photo-transfer.cfg"); + std::ifstream image(imagePath, std::ios::binary | std::ios::ate); + constexpr std::size_t photoBytes = 600U * sppm::kHeight * 4U; + if (!image || image.tellg() != static_cast(photoBytes)) return false; + photoImportRgba.resize(photoBytes); + image.seekg(0, std::ios::beg); + image.read(reinterpret_cast(photoImportRgba.data()), static_cast(photoImportRgba.size())); + if (!image) return false; + std::ifstream options(optionsPath, std::ios::binary); + int targetLayer = -1; + std::string mode, dither, size, offsetX, offsetY; + std::string line; + if (!std::getline(options, line)) return false; + targetLayer = std::atoi(line.c_str()); + if (!std::getline(options, mode) || !std::getline(options, dither) || + !std::getline(options, size) || !std::getline(options, offsetX) || !std::getline(options, offsetY) || + targetLayer < 0 || targetLayer >= 4) return false; + (void)mode; (void)dither; (void)size; (void)offsetX; (void)offsetY; + photoImportLayer = targetLayer; + photoImportRow = 0; + layer = targetLayer; + pushUndo(); + std::fill(doc.frames[frame].layers[static_cast(targetLayer)].begin(), + doc.frames[frame].layers[static_cast(targetLayer)].end(), 0); + photoImporting = true; + beginLoading(); + return true; +} + +void App::advancePhotoImport() { + if (!photoImporting) return; + constexpr int rowsPerUpdate = 4; + auto& pixels = doc.frames[frame].layers[static_cast(photoImportLayer)]; + const int endRow = std::min(sppm::kHeight, photoImportRow + rowsPerUpdate); + for (int y = photoImportRow; y < endRow; ++y) { + for (int x = 0; x < sppm::kWidth; ++x) { + // The browser preview is 600x360 while the Switchnote canvas is + // 640x360. Center that one preview exactly and never walk through + // the next row or past the uploaded buffer. + const int sourceX = x - 20; + if (sourceX < 0 || sourceX >= 600) continue; + const std::size_t source = (static_cast(y) * 600U + static_cast(sourceX)) * 4U; + if (photoImportRgba[source + 3] < 24) continue; + int bestError = std::numeric_limits::max(); + std::uint8_t bestIndex = 1; + for (int index : kInkPresetIndexes) { + const auto& candidate = doc.palette[static_cast(index)]; + const int dr = static_cast(candidate.r) - photoImportRgba[source]; + const int dg = static_cast(candidate.g) - photoImportRgba[source + 1]; + const int db = static_cast(candidate.b) - photoImportRgba[source + 2]; + const int error = dr * dr * 3 + dg * dg * 6 + db * db * 2; + if (error < bestError) { bestError = error; bestIndex = static_cast(index); } + } + pixels[static_cast(y) * sppm::kWidth + static_cast(x)] = bestIndex; + } + } + photoImportRow = endRow; + if (photoImportRow < sppm::kHeight) return; + markDirty(); + invalidateFrames(); + const bool saved = saveDocument(); + std::remove(join(importRoot, "photo-transfer.rgba").c_str()); + std::remove(join(importRoot, "photo-transfer.cfg").c_str()); + audioUploadServer.finishCompletedUpload(saved); + audioUploadStatus = saved ? "Photo added to your Switchnote." : "Photo import could not be saved."; + if (saved) showToast("Photo imported to layer " + std::string(1, static_cast('A' + photoImportLayer))); + photoImportRgba.clear(); + photoImporting = false; + endLoading(); +} + +void App::renderModal() { + renderingModal = true; + fill(0, 0, W, H, SDL_Color{10, 15, 24, 126}); + const float progress = std::clamp((SDL_GetTicks() - modalTransitionStart) / 150.0f, 0.0f, 1.0f); + const float eased = modalClosing ? progress * progress * progress + : 1.0f - (1.0f - progress) * (1.0f - progress) * (1.0f - progress); + const bool galleryTerms = modal == Modal::GalleryTerms; + const bool updateScreen = modal == Modal::UpdateAvailable; + const int x = (galleryTerms || updateScreen) ? 42 : 330; + const int w = (galleryTerms || updateScreen) ? 1196 : 620; + const int h = (galleryTerms || updateScreen) ? 630 : 460; + const int targetY = (galleryTerms || updateScreen) ? 45 : 130; + const int y = modalClosing ? targetY - static_cast(eased * (targetY + h)) + : -h + static_cast(eased * (targetY + h)); + rounded(x, y + 6, w, h, 22, SDL_Color{0, 0, 0, 55}); + rounded(x, y, w, h, 22, WHITE); + auto title = [&](const std::string& value) { centered(value, x + 24, y + 20, w - 48, 42, 28, INK, true); }; + auto dismiss = [&]() { + if (pill("Close", x + w - 135, y + h - 64, 108, 42, false, 18)) { + if (modal == Modal::Photo || modal == Modal::PhotoChoice || modal == Modal::PhotoUpload) closePhotoCapture(); + else if (modal == Modal::Audio) closeAudioMenu(); + else if (modal == Modal::Branding) closeBrandingMenu(); + else modal = Modal::None; + } + }; + switch (modal) { + case Modal::GalleryTerms: { + centered("Switchnote Gallery: Alpha Server!", x + 56, y + 64, w - 112, 62, 39, INK, true); + text("By using services provided by RexiMemo, you agree to the Terms of Use", x + 128, y + 170, 23, INK); + text("and acknowledge the Privacy Policy.", x + 128, y + 204, 23, INK); + text("You must be 13 years of age or older to use RexiMemo, including", x + 128, y + 258, 23, INK); + text("Switchnote Gallery.", x + 128, y + 292, 23, INK); + text("Switchnote/RexiMemo does not sell personal information or use it for", x + 128, y + 346, 23, INK); + text("targeted advertising.", x + 128, y + 380, 23, INK); + text("Terms:", x + 128, y + 436, 22, INK); + text("https://reximemo.net/terms", x + 690, y + 436, 22, INK); + text("Privacy:", x + 128, y + 474, 22, INK); + text("https://reximemo.net/privacy", x + 690, y + 474, 22, INK); + if (actionPill("ui/icons/ButtonBText.png", "Back", x + 415, y + 548, 170, 54, false, true, "cursor_cancel")) { + modal = Modal::None; + screen = Screen::Library; + } + if (actionPill("ui/icons/ButtonAGalleryText.png", "I agree", x + 610, y + 548, 190, 54, true, true, "cursor_select")) { + galleryTermsAccepted = true; + std::ofstream accepted(join(dataRoot, "gallery-terms-accepted"), std::ios::binary | std::ios::trunc); + if (accepted) accepted << "1\n"; + modal = Modal::None; + } + break; + } + case Modal::Credits: + title("Credits"); + centered("Homebrew application by: RexiMemo!", x + 36, y + 120, w - 72, 36, 20, INK); + centered("Testing during development: meulin, LatteWiiU", x + 36, y + 180, w - 72, 36, 20, INK); + centered("UI Design originally by: Jaames", x + 36, y + 240, w - 72, 36, 20, INK); + dismiss(); + break; + case Modal::GalleryPostChoice: + title("Post to Gallery"); + centered("Choose whether other users can edit this Switchnote.", x + 34, y + 116, w - 68, 54, 18, INK); + centered("A locked Switchnote stays read-only when downloaded.", x + 34, y + 166, w - 68, 42, 16, GRAY); + if (actionPill("ui/icons/ButtonXText.png", "Post with Lock", x + 58, y + 258, 240, 56, false, true, "cursor_select")) { + modal = Modal::None; performGalleryUpload(pendingGalleryPublishIndex, true); + } + if (actionPill("ui/icons/ButtonYText.png", "Post without Lock", x + 322, y + 258, 240, 56, false, true, "cursor_select")) { + modal = Modal::None; performGalleryUpload(pendingGalleryPublishIndex, false); + } + if (actionPill("ui/icons/ButtonBText.png", "Cancel", x + 226, y + 350, 168, 52, false, true, "cursor_cancel")) modal = Modal::None; + break; + case Modal::GalleryNotice: { + title("Leaving " + appDisplayName); + centered("You are now leaving " + appDisplayName + ".", x + 42, y + 112, w - 84, 38, 20, INK); + centered("The following services are provided by", x + 42, y + 165, w - 84, 34, 19, GRAY); + centered("RexiMemo.net.", x + 42, y + 201, w - 84, 34, 20, INK, true); + if (actionPill("ui/icons/ButtonBText.png", "Back", x + 95, y + h - 82, 170, 54, false, true, "cursor_cancel")) { + modal = Modal::None; + } + if (actionPill("ui/icons/ButtonAText.png", "OK", x + 355, y + h - 82, 170, 54, false, true, "cursor_select")) { + confirmGalleryLeave(); + } + break; + } + case Modal::GalleryOffline: { + title("No Internet Connection"); + centered("You don't have an active internet connection.", x + 38, y + 118, w - 76, 34, 18, INK); + const std::string before = "Please "; + const std::string emphasized = "safely"; + const std::string after = " connect your Nintendo Switch"; + TTF_Font* bodyFont = font(18, false); + int beforeWidth = 0, emphasizedWidth = 0, afterWidth = 0, ignoredHeight = 0; + if (bodyFont) { + TTF_SizeUTF8(bodyFont, before.c_str(), &beforeWidth, &ignoredHeight); + TTF_SizeUTF8(bodyFont, emphasized.c_str(), &emphasizedWidth, &ignoredHeight); + TTF_SizeUTF8(bodyFont, after.c_str(), &afterWidth, &ignoredHeight); + } + const int lineWidth = beforeWidth + emphasizedWidth + afterWidth; + const int lineX = x + (w - lineWidth) / 2; + rawText(before, lineX, y + 177, 18, INK, false); + rawText(emphasized, lineX + beforeWidth, y + 177, 18, INK, false); + rawText(emphasized, lineX + beforeWidth + 1, y + 177, 18, INK, false); + rawText(after, lineX + beforeWidth + emphasizedWidth, y + 177, 18, INK, false); + centered("to the internet, then try again.", x + 38, y + 207, w - 76, 34, 18, INK); + if (actionPill("ui/icons/ButtonBText.png", "Back", x + 95, y + h - 82, 170, 54, false, true, "cursor_cancel")) { + modal = Modal::None; + } + if (actionPill("ui/icons/ButtonAText.png", "OK", x + 355, y + h - 82, 170, 54, false, true, "cursor_select")) { + modal = Modal::None; + } + break; + } + case Modal::Folder: { + title("Folder Options"); + std::vector folders; + if (DIR* dir = opendir(notesRoot.c_str())) { + while (dirent* ent = readdir(dir)) { + const std::string name = ent->d_name; + if (name.empty() || name[0] == '.') continue; + const std::string full = join(notesRoot, name); + struct stat st{}; + if (stat(full.c_str(), &st) == 0 && (st.st_mode & S_IFDIR)) folders.push_back(name); + } + closedir(dir); + } + std::sort(folders.begin(), folders.end()); + for (std::size_t i = 0; i < folders.size() && i < 6; ++i) { + const int by = y + 92 + static_cast(i) * 50; + if (pill(folders[i], x + 52, by, w - 104, 42, + folders[i] == folder || static_cast(i) == modalSelection, 19, true, "folder_open")) { + folder = folders[i]; selected = 0; scanLibrary(); modal = Modal::None; + } + } + if (pill("New Folder +", x + 70, y + h - 67, 220, 42, false, 19)) { modal = Modal::None; beginText(Field::FolderName, ""); } + dismiss(); + break; + } + case Modal::Audio: { + title("Sound"); + rounded(x + 40, y + 75, w - 80, 75, 12, LIGHT); + if (serverStartRunning && serverStartTask == ServerStartTask::Audio) { + centered("Starting the local sound server…", x + 52, y + 84, w - 104, 54, 18, GRAY); + } else if (audioUploadServer.isRunning()) { + text(audioUploadUrl, x + 53, y + 82, 18, INK); + if (audioUploadServer.isConnected()) text("Connected", x + 53, y + 111, 22, GREEN, true); + else text("Code " + audioUploadCode, x + 53, y + 111, 22, ORANGE, true); + } else { + centered(audioUploadStatus, x + 52, y + 83, w - 104, 56, 17, GRAY); + } + for (int i = 0; i < 4; ++i) { + const int bx = x + 46 + i * 132; + if (pill(i == 0 ? "BGM" : "SE" + std::to_string(i), bx, y + 164, 122, 42, i == audioTrack, 17)) { + audioTrack = i; + audioEraseArmedTrack = -1; + if (audioReady) Mix_HaltChannel(4); + } + } + const bool trackPresent = audioTrack < static_cast(doc.audioTracks.size()) && + !doc.audioTracks[static_cast(audioTrack)].pcm.empty(); + const sppm::AudioTrack* selectedTrack = trackPresent ? &doc.audioTracks[static_cast(audioTrack)] : nullptr; + const double seconds = selectedTrack && selectedTrack->channels && selectedTrack->sampleRate + ? selectedTrack->pcm.size() / static_cast(selectedTrack->channels * selectedTrack->sampleRate) : 0.0; + const std::string durationText = std::to_string(static_cast(std::lround(seconds * 10)) / 10) + "." + + std::to_string(static_cast(std::lround(seconds * 10)) % 10) + " s"; + text(trackPresent ? "Audio: " + durationText : "No audio in this track", x + 53, y + 221, 18, + trackPresent ? INK : GRAY); + if (audioTrack == 0) { + text("Music timing", x + 53, y + 255, 17, GRAY); + if (pill("Attached to speed", x + 48, y + 282, 241, 43, doc.metadata.bgmFollowsSpeed, 16)) { + if (!doc.metadata.bgmFollowsSpeed) { doc.metadata.bgmFollowsSpeed = true; markDirty(); rebuildAudio(); } + } + if (pill("Loose timing", x + 312, y + 282, 241, 43, !doc.metadata.bgmFollowsSpeed, 16)) { + if (doc.metadata.bgmFollowsSpeed) { doc.metadata.bgmFollowsSpeed = false; markDirty(); rebuildAudio(); } + } + } else { + bool cueHere = false; + if (audioTrack < static_cast(doc.audioTracks.size())) { + const auto& cues = doc.audioTracks[static_cast(audioTrack)].frameTriggers; + cueHere = std::find(cues.begin(), cues.end(), static_cast(frame)) != cues.end(); + } + text("Frame " + std::to_string(frame + 1) + " sound effect", x + 53, y + 255, 17, GRAY); + if (pill(cueHere ? "Cue on this frame: On" : "Cue on this frame: Off", x + 48, y + 282, 260, 43, cueHere, 16)) { + while (doc.audioTracks.size() <= static_cast(audioTrack)) { + sppm::AudioTrack blank; + blank.name = doc.audioTracks.empty() ? "BGM" : "SE" + std::to_string(doc.audioTracks.size()); + blank.sampleRate = 48000; blank.channels = 2; + doc.audioTracks.push_back(std::move(blank)); + } + auto& cues = doc.audioTracks[static_cast(audioTrack)].frameTriggers; + const auto cue = static_cast(frame); + auto it = std::lower_bound(cues.begin(), cues.end(), cue); + if (it != cues.end() && *it == cue) cues.erase(it); else cues.insert(it, cue); + markDirty(); + } + text("SE stays at its recorded speed.", x + 329, y + 293, 15, GRAY); + } + if (actionPill("ui/icons/Play.png", audioReady && Mix_Playing(4) ? "Stop" : "Preview", + x + 48, y + 342, 180, 42, false, + trackPresent && audioReady && trackChunks[static_cast(audioTrack)], "")) { + if (Mix_Playing(4)) Mix_HaltChannel(4); + else if (trackChunks[static_cast(audioTrack)]) + Mix_PlayChannel(4, trackChunks[static_cast(audioTrack)], 0); + } + if (!importFiles.empty()) { + const std::string& filename = importFiles[importSelection % importFiles.size()]; + centered(filename, x + 245, y + 342, 225, 42, 14, INK); + if (pill("<", x + 475, y + 342, 38, 42, false, 17)) + importSelection = importSelection ? importSelection - 1 : importFiles.size() - 1; + if (pill(">", x + 518, y + 342, 38, 42, false, 17)) + importSelection = (importSelection + 1) % importFiles.size(); + } + if (audioEraseArmedTrack == audioTrack) { + if (dangerPill("Confirm erase", x + 48, y + 405, 145, 42, 15, trackPresent)) { + if (audioReady) Mix_HaltChannel(4); + auto& removed = doc.audioTracks[static_cast(audioTrack)]; + removed.pcm.clear(); + removed.frameTriggers.clear(); + removed.referenceFps = 0; + removed.referenceSpeed = 0; + markDirty(); + rebuildAudio(); + saveDocument(true); + audioEraseArmedTrack = -1; + } + } else if (pill("Erase track", x + 48, y + 405, 145, 42, false, 16, trackPresent)) { + audioEraseArmedTrack = audioTrack; + } + if (pill("Import SD WAV", x + 208, y + 405, 195, 42, false, 16, !importFiles.empty())) { + audioEraseArmedTrack = -1; + importAudio(); + } + if (pill("Close", x + 478, y + 405, 108, 42, false, 18, + !(serverStartRunning && serverStartTask == ServerStartTask::Audio))) closeAudioMenu(); + break; + } + case Modal::Branding: { + title("Branding Config Portal"); + rounded(x + 40, y + 92, w - 80, 105, 12, LIGHT); + if (serverStartRunning && serverStartTask == ServerStartTask::Branding) { + centered("Starting the branding portal…", x + 56, y + 113, w - 112, 56, 18, GRAY); + } else if (brandingConfigServer.isRunning()) { + text(brandingUrl, x + 58, y + 108, 21, INK); + if (brandingConfigServer.isConnected()) text("Connected", x + 58, y + 150, 22, GREEN, true); + else text("Code " + brandingCode, x + 58, y + 150, 22, ORANGE, true); + } else centered(brandingStatus, x + 56, y + 111, w - 112, 68, 18, GRAY); + centered("Open the address on your PC, then enter the code", x + 56, y + 225, w - 112, 32, 18, INK); + centered("to change the app name, logo, and Switchnote names.", x + 56, y + 256, w - 112, 32, 18, INK); + centered("Restart the app before branding changes appear.", x + 56, y + 327, w - 112, 42, 16, GRAY); + if (pill("Close", x + w - 135, y + h - 64, 108, 42, false, 18, + !(serverStartRunning && serverStartTask == ServerStartTask::Branding))) closeBrandingMenu(); + break; + } + case Modal::Export: { + title("Export Switchnote"); + text("Choose where to export this Switchnote.", x + 60, y + 100, 20, INK); + if (pill("Export to SD:\\Switchnotes · MP4", x + 74, y + 151, 465, 58, modalSelection == 0, 20)) { + modal = Modal::None; exportDocument(0); + } + if (pill("Export to System Captures", x + 74, y + 226, 465, 58, modalSelection == 1, 20)) { + modal = Modal::None; exportDocument(1); + } + text("MP4 export includes animation only; audio is not included.", x + 60, y + 306, 17, GRAY); + dismiss(); + break; + } + case Modal::ConfirmDelete: + title("Delete this Switchnote?"); + centered("This action cannot be undone.", x + 25, y + 126, w - 50, 38, 20, GRAY); + if (actionPill("ui/icons/ButtonBText.png", "Cancel", x + 108, y + 234, 160, 52, false, true, "cursor_cancel")) modal = Modal::None; + if (dangerActionPill("ui/icons/ButtonZRText.png", "Delete", x + 342, y + 234, 160, 52, 19)) deleteDocument(); + break; + case Modal::ConfirmGalleryDelete: + title("Remove from Switchnote Gallery?"); + centered("Only the original poster can remove this Switchnote.", x + 25, y + 126, w - 50, 38, 18, GRAY); + if (pill("Cancel", x + 108, y + 234, 160, 52, modalSelection == 0, 20)) modal = Modal::None; + if (dangerPill("Remove", x + 342, y + 234, 160, 52, 20)) deleteGalleryItem(); + break; + case Modal::Options: { + title("Editor Options"); + auto setSpeed = [&](int speed) { + defaultSpeed = speed; + defaultFps = kSpeedFps[static_cast(speed - 1)]; + if (docLoaded) { + doc.metadata.fps = static_cast(defaultFps); + doc.metadata.speed = static_cast(speed); + markDirty(); + if (doc.metadata.bgmFollowsSpeed) rebuildAudio(); + } + savePreferences(); + }; + text("Animation speed", x + 60, y + 88, 18, GRAY); + const int currentSpeed = docLoaded ? documentSpeed(doc.metadata) : defaultSpeed; + for (int speed = 1; speed <= 8; ++speed) { + const int bx = x + 58 + (speed - 1) * 64; + if (pill(std::to_string(speed), bx, y + 118, 54, 46, currentSpeed == speed, 20)) setSpeed(speed); + } + if (pill(std::string("Onion skin: ") + (onion ? "On" : "Off"), x + 58, y + 202, 220, 50, onion, 18)) { + onion = !onion; + savePreferences(); + } + if (pill(std::string("Canvas grid: ") + (canvasGrid ? "On" : "Off"), x + 312, y + 202, 220, 50, canvasGrid, 18)) { + canvasGrid = !canvasGrid; + savePreferences(); + } + if (pill(std::string("Copy to new page: ") + (copyToNewPage ? "On" : "Off"), + x + 58, y + 274, 260, 48, copyToNewPage, 17)) { + copyToNewPage = !copyToNewPage; + savePreferences(); + } + if (pill(std::string("Loop: ") + (docLoaded && doc.metadata.loopPlayback ? "On" : "Off"), + x + 312, y + 274, 220, 48, docLoaded && doc.metadata.loopPlayback, 18, docLoaded)) { + doc.metadata.loopPlayback = !doc.metadata.loopPlayback; + markDirty(); + } + if (pill(std::string("Allow spin-offs: ") + (docLoaded && doc.metadata.allowSpinoffs ? "On" : "Off"), + x + 58, y + 338, 260, 48, docLoaded && doc.metadata.allowSpinoffs, 17, docLoaded)) { + doc.metadata.allowSpinoffs = !doc.metadata.allowSpinoffs; + markDirty(); + } + dismiss(); + break; + } + case Modal::PhotoProbe: { + title("Add a Photo"); + centered("Checking for an IR camera…", x + 52, y + 155, w - 104, 68, 20, GRAY); + break; + } + case Modal::PhotoChoice: { + title("Add a Photo"); + centered("Choose how to add an image to this Switchnote.", x + 55, y + 88, w - 110, 36, 18, GRAY); + if (pill("Import Image from Album", x + 80, y + 135, 460, 58, false, 20)) openPhotoAlbum(); + if (pill("Upload from Another Device", x + 80, y + 207, 460, 58, false, 20)) { + openPhotoUpload(); + photoModalTransitionDim = true; + photoModalTransitionDimAt = SDL_GetTicks(); + } +#ifdef __SWITCH__ + if (irCameraRunning && pill("Take Photo with IR Camera", x + 80, y + 279, 460, 58, false, 20)) { + modal = Modal::Photo; + modalTransitionStart = SDL_GetTicks(); + } +#endif + if (pill("Cancel", x + 440, y + 377, 120, 48, false, 16)) closePhotoCapture(); + break; + } + case Modal::PhotoAlbum: { + title("Import Image from Album"); +#ifdef __SWITCH__ + const std::size_t pageStart = static_cast(photoAlbumPage) * photoAlbumThumbs.size(); + if (R_SUCCEEDED(capsaInitialize())) { + for (std::size_t slot = 0; slot < photoAlbumThumbs.size(); ++slot) { + const std::size_t index = pageStart + slot; + if (photoAlbumThumbIndexes[slot] == index || index >= photoAlbumEntries.size()) continue; + if (photoAlbumThumbs[slot]) SDL_DestroyTexture(photoAlbumThumbs[slot]); + photoAlbumThumbs[slot] = nullptr; + std::vector jpeg(1024U * 1024U); + u64 bytes = 0; + if (R_SUCCEEDED(capsaLoadAlbumFileThumbnail(&photoAlbumEntries[index].file_id, &bytes, + jpeg.data(), jpeg.size())) && bytes > 0 && bytes <= jpeg.size()) { + SDL_RWops* thumbStream = SDL_RWFromConstMem(jpeg.data(), static_cast(bytes)); + SDL_Surface* thumbSurface = thumbStream ? IMG_Load_RW(thumbStream, 1) : nullptr; + if (thumbSurface) { + photoAlbumThumbs[slot] = SDL_CreateTextureFromSurface(renderer, thumbSurface); + SDL_FreeSurface(thumbSurface); + } + } + photoAlbumThumbIndexes[slot] = index; + } + capsaExit(); + } + if (photoAlbumEntries.empty()) centered("No screenshots in the album.", x + 45, y + 150, w - 90, 46, 19, GRAY); + for (std::size_t slot = 0; slot < photoAlbumThumbs.size(); ++slot) { + const std::size_t index = pageStart + slot; + if (index >= photoAlbumEntries.size()) break; + const int col = static_cast(slot % 3), row = static_cast(slot / 3); + const int tx = x + 35 + col * 182, ty = y + 82 + row * 124; + rounded(tx - 2, ty - 2, 168, 104, 10, SDL_Color{210, 214, 220, 255}); + rounded(tx, ty, 164, 100, 8, WHITE); + if (photoAlbumThumbs[slot]) drawTexture(photoAlbumThumbs[slot], tx + 2, ty + 2, 160, 96); + else centered("Image", tx + 2, ty + 25, 160, 40, 17, GRAY); + if (hit(tx - 3, ty - 3, 170, 106)) importPhotoAlbumEntry(index); + } + const int pages = std::max(1, static_cast((photoAlbumEntries.size() + photoAlbumThumbs.size() - 1) / photoAlbumThumbs.size())); + if (photoAlbumPage > 0 && pill("<", x + 35, y + 350, 64, 44, false, 22)) { + --photoAlbumPage; + photoAlbumThumbIndexes.fill(std::numeric_limits::max()); + } + centered(std::to_string(photoAlbumPage + 1) + " / " + std::to_string(pages), x + 225, y + 350, 170, 44, 17, GRAY); + if (photoAlbumPage + 1 < pages && pill(">", x + 521, y + 350, 64, 44, false, 22)) { + ++photoAlbumPage; + photoAlbumThumbIndexes.fill(std::numeric_limits::max()); + } +#else + centered("The Switch photo album is only available on Nintendo Switch.", x + 45, y + 150, w - 90, 56, 18, GRAY); +#endif + break; + } + case Modal::PhotoUpload: { + title("Upload Photo"); + const bool photoServerStarting = serverStartRunning && serverStartTask == ServerStartTask::Photo; + if (photoServerStarting) centered("Starting the local photo server…", x + 42, y + 108, w - 84, 40, 18, GRAY); + else { + if (!audioUploadUrl.empty()) centered(audioUploadUrl, x + 42, y + 94, w - 84, 36, 19, INK); + if (audioUploadServer.isConnected()) centered("Connected", x + 70, y + 139, 200, 34, 19, GREEN, true); + else centered("Code " + audioUploadCode, x + 70, y + 139, 300, 34, 19, ORANGE, true); + } + centered("Open this address on your PC to choose an image and set", x + 42, y + 202, w - 84, 28, 16, GRAY); + centered("size, position, layer, color mode and dithering.", x + 42, y + 229, w - 84, 28, 16, GRAY); + centered(photoServerStarting ? "Preparing transfer page…" : audioUploadStatus, + x + 42, y + 281, w - 84, 32, 17, INK); + if (pill("Close", x + 440, y + 372, 120, 48, false, 16, !photoServerStarting)) closePhotoCapture(); + break; + } + case Modal::Photo: { + title("IR Photo Import"); +#ifdef __SWITCH__ + if (irPhotoTaken || irImageReady) { + if (irPreviewTexture) { + SDL_Rect previewRect{x + 150, y + 78, 320, 240}; + SDL_RenderCopyEx(renderer, irPreviewTexture, nullptr, &previewRect, irRotatePhoto ? 90.0 : 0.0, nullptr, SDL_FLIP_NONE); + } + else centered("Camera preview unavailable", x + 50, y + 100, w - 100, 180, 20, GRAY); + if (!irRotatePhoto) centered("Photo replaces the selected layer. Undo is available.", x + 45, y + 322, w - 90, 30, 15, GRAY); + } else centered(irsReady ? "Starting IR camera…" : "Connect a Right Joy-Con with an IR camera.", + x + 50, y + 112, w - 100, 150, 19, INK); + if (!irPhotoTaken) { + if (pill(irImageReady ? "Take Photo" : "Waiting…", x + 40, y + 372, 120, 48, false, 16, irImageReady)) { + irPhoto = irImage; + irPhotoTaken = true; + for (std::size_t i = 0; i < irPhoto.size(); ++i) { + irPreviewRgba[i * 4] = irPhoto[i]; irPreviewRgba[i * 4 + 1] = irPhoto[i]; irPreviewRgba[i * 4 + 2] = irPhoto[i]; + } + if (irPreviewTexture) SDL_UpdateTexture(irPreviewTexture, nullptr, irPreviewRgba.data(), 320 * 4); + } + if (pill(std::string("Rotate: ") + (irRotatePhoto ? "On" : "Off"), x + 170, y + 372, 120, 48, irRotatePhoto, 16)) irRotatePhoto = !irRotatePhoto; + } else { + if (pill("Retake", x + 40, y + 372, 120, 48, false, 16)) irPhotoTaken = false; + if (pill(std::string("Rotate: ") + (irRotatePhoto ? "On" : "Off"), x + 170, y + 372, 120, 48, irRotatePhoto, 16)) irRotatePhoto = !irRotatePhoto; + if (pill("Use Photo", x + 300, y + 372, 120, 48, true, 16)) importPhoto(); + } +#else + centered("Photo capture is available on Nintendo Switch.", x + 50, y + 125, w - 100, 130, 19, INK); +#endif + if (pill("Cancel", x + 440, y + 372, 120, 48, false, 16)) closePhotoCapture(); + break; + } + case Modal::Help: + title("Controls"); + text("Touch / mouse", x + 52, y + 90, 19, ORANGE, true); + text("Draw on the canvas, select tools, colors and frames.", x + 52, y + 121, 17, INK); + text("A / Enter Select or play", x + 52, y + 164, 18, INK); + text("B / Esc Back", x + 52, y + 200, 18, INK); + text("Y Edit selected Switchnote", x + 52, y + 236, 18, INK); + text("L / R Previous / next layer", x + 52, y + 272, 18, INK); + text("ZL / ZR Undo / redo", x + 52, y + 308, 18, INK); + dismiss(); + break; + case Modal::UpdateAvailable: { + rounded(x, y + 7, w, h, 24, SDL_Color{0, 0, 0, 45}); + rounded(x, y, w, h, 24, UPDATE_ORANGE); + roundedOutline(x, y, w, h, 24, 2, WHITE); + centered("Update Available", x + 36, y + 48, w - 72, 62, 39, WHITE); + text("A new update is available for " + appDisplayName + ":", x + 134, y + 194, 27, WHITE); + centered(appDisplayName + ", " + updateServerVersion, x + 100, y + 276, w - 200, 42, 27, WHITE); + text("Author:", x + 134, y + 344, 26, WHITE); + text("Version:", x + 134, y + 373, 26, WHITE); + text("File SHA256:", x + 134, y + 402, 26, WHITE); + text("RexiMemo!", x + 922, y + 344, 26, WHITE); + text(updateServerVersion, x + 946, y + 373, 26, WHITE); + text(updateSha256.empty() ? "Not provided by update service" : updateSha256, + x + 448, y + 410, updateSha256.empty() ? 18 : 15, WHITE); + text("Please run the app from the Homebrew Menu in App Override mode, or", x + 134, y + 462, 25, WHITE); + text("install the official NSP file.", x + 134, y + 491, 25, WHITE); + if (pill("Update", x + 528, y + 548, 140, 50, true, 26, true, "cursor_select")) startUpdateDownload(); + break; + } + case Modal::TextInput: + title(editingField == Field::Title ? "Switchnote Title" : editingField == Field::Description ? "Description" : + editingField == Field::FolderName ? "New Folder" : editingField == Field::GalleryBio ? "Gallery Bio" : "Comment"); + rounded(x + 44, y + 118, w - 88, 64, 12, LIGHT); + text(inputText + "▏", x + 60, y + 132, 21, INK); + if (pill("Cancel", x + 92, y + 244, 160, 48, false, 19)) { modal = Modal::None; editingField = Field::None; SDL_StopTextInput(); } + if (pill("Done", x + 365, y + 244, 160, 48, false, 19)) applyText(); + break; + case Modal::None: break; + } + renderingModal = false; +} + +bool switchnoteBrushPattern(int id, int x, int y) { + switch (id & 7) { + case 0: return x % 3 == 1 && y % 3 == 1; + case 1: return (x & 1) == 0 && (y & 1) == 0; + case 2: return (x & 1) == 1; + case 3: return (y & 1) == 1; + case 4: return ((x + y) & 1) == 1; + case 5: return (x & 1) != 0 || (y & 1) != 0; + case 6: return !(x % 3 == 1 && y % 3 == 1); + case 7: return ((x - y) % 5 == 0) || ((x + y) % 5 == 0); + default: return false; + } +} + +bool switchnoteEraserContains(int slot, int dx, int dy) { + switch (slot & 7) { + case 0: return dx == 0 && dy == 0; + case 1: return std::abs(dx) <= 1 && std::abs(dy) <= 1; + case 2: return dx * dx + dy * dy <= 12; + case 3: return dx * dx + dy * dy <= 42; + case 4: return std::abs(dx) <= 4 && std::abs(dy) <= 4; + case 5: return std::abs(dx) <= 7 && std::abs(dy) <= 7; + case 6: return std::abs(dx) <= 1 && std::abs(dy) <= 6; + case 7: return std::abs(dy) <= 1 && std::abs(dx) <= 6; + default: return false; + } +} + +bool App::paintPixel(int sx, int sy, std::vector& pixels) { + const float scale = screen == Screen::CommentCanvas ? 2.0f : 2.0f * zoom; + const int width = static_cast(sppm::kWidth * scale); + const int left = screen == Screen::CommentCanvas ? 0 : (W - width) / 2 + panX; + const int height = static_cast(sppm::kHeight * scale); + const int top = screen == Screen::CommentCanvas ? 0 : (H - height) / 2 + panY; + // Use the same rounded destination dimensions as the renderer. Dividing + // by the requested float scale drifted by a pixel at fractional zoom. + const float pixelWidth = width / static_cast(sppm::kWidth); + const float pixelHeight = height / static_cast(sppm::kHeight); + const int px = static_cast(std::floor((sx - left) / pixelWidth)); + const int py = static_cast(std::floor((sy - top) / pixelHeight)); + if (px < 0 || py < 0 || px >= sppm::kWidth || py >= sppm::kHeight) return false; + int previousPx = px, previousPy = py; + if (hasLastStroke) { + previousPx = static_cast(std::floor((lastStrokeX - left) / pixelWidth)); + previousPy = static_cast(std::floor((lastStrokeY - top) / pixelHeight)); + } + const int motionX = hasLastStroke ? px - previousPx : 0; + const int motionY = hasLastStroke ? py - previousPy : 0; + const bool usePattern = screen != Screen::CommentCanvas && activeTool == Tool::Brush; + const bool erase = screen != Screen::CommentCanvas && activeTool == Tool::Eraser; + const bool usePen = screen != Screen::CommentCanvas && activeTool == Tool::Pen; + bool changed = false; + const std::uint8_t value = screen == Screen::CommentCanvas ? 1 : + (erase ? 0 : static_cast(std::clamp(color, 1, 255))); + auto write = [&](int x, int y) { + if (x < 0 || y < 0 || x >= sppm::kWidth || y >= sppm::kHeight) return; + const auto index = static_cast(y) * sppm::kWidth + x; + if (pixels[index] != value) { pixels[index] = value; changed = true; } + }; + auto clearInk = [&](int x, int y) { + if (x < 0 || y < 0 || x >= sppm::kWidth || y >= sppm::kHeight) return; + const auto index = static_cast(y) * sppm::kWidth + x; + if (pixels[index] != 0) { pixels[index] = 0; changed = true; } + }; + auto writePen = [&](int x, int y) { + const int size = std::clamp(brush, 1, 32); + const int start = -((size - 1) / 2); + const float center = static_cast(start) + (size - 1) / 2.0f; + const float radius = std::max(0.5f, size / 2.0f); + for (int oy = 0; oy < size; ++oy) for (int ox = 0; ox < size; ++ox) { + const int dx = start + ox, dy = start + oy; + const float ddx = dx - center, ddy = dy - center; + if (!square && ddx * ddx + ddy * ddy > radius * radius) continue; + write(x + dx, y + dy); + } + }; + auto stampSquare = [&](int x, int y, int radius) { + for (int dy = -radius; dy <= radius; ++dy) + for (int dx = -radius; dx <= radius; ++dx) writePen(x + dx, y + dy); + }; + if (usePattern) { + // Match the web editor's eight paint predicates on absolute canvas + // coordinates. Normal paint is a 3x3 mask; Advanced is a 15x15 mask. + const int radius = advancedBrush ? 7 : 1; + for (int dy = -radius; dy <= radius; ++dy) + for (int dx = -radius; dx <= radius; ++dx) + if (switchnoteBrushPattern(pattern, px + dx, py + dy)) write(px + dx, py + dy); + } else if (erase) { + static constexpr std::array radii{{0, 1, 3, 6, 4, 7, 6, 6}}; + const int radius = radii[static_cast(eraserSlot & 7)]; + for (int dy = -radius; dy <= radius; ++dy) + for (int dx = -radius; dx <= radius; ++dx) + if (switchnoteEraserContains(eraserSlot, dx, dy)) write(px + dx, py + dy); + } else if (!usePen) { + const int size = std::clamp(brush, 1, 32); + const int start = -((size - 1) / 2); + const float center = static_cast(start) + (size - 1) / 2.0f; + const float radius = std::max(0.5f, size / 2.0f); + for (int oy = 0; oy < size; ++oy) for (int ox = 0; ox < size; ++ox) { + const int dx = start + ox, dy = start + oy; + const float ddx = dx - center, ddy = dy - center; + if (!square && ddx * ddx + ddy * ddy > radius * radius) continue; + write(px + dx, py + dy); + } + } else { + auto random16 = [&]() -> std::uint16_t { + const std::uint16_t old = penRngState; + const std::uint16_t bit0 = old & 1u; + const std::uint16_t bit3 = (old >> 3) & 1u; + penRngState = old >> 1; + if (bit0 == bit3) penRngState = static_cast(penRngState | 0x8000u); + return penRngState; + }; + auto randomRange = [&](int range) { return static_cast(random16() % static_cast(std::max(1, range))); }; + auto drawLine = [&](int x0, int y0, int x1, int y1, bool includeStart) { + int x = x0, y = y0; + const int dx = std::abs(x1 - x), stepX = x < x1 ? 1 : -1; + const int dyAbs = std::abs(y1 - y), stepY = y < y1 ? 1 : -1; + int error = dx - dyAbs; + bool first = true; + while (true) { + if (includeStart || !first) writePen(x, y); + if (x == x1 && y == y1) break; + first = false; + const int twice = 2 * error; + if (twice > -dyAbs) { error -= dyAbs; x += stepX; } + if (twice < dx) { error += dx; y += stepY; } + } + }; + auto stampDirectionAware = [&]() { + writePen(px, py); + if (motionX || motionY) { + writePen(px + motionY, py); + writePen(px, py + motionX); + } + }; + auto stampDirectionalEqual = [&]() { + const bool horizontal = motionX != 0 && motionY == 0; + const bool vertical = motionY != 0 && motionX == 0; + if (horizontal) { writePen(px, py - 1); writePen(px, py + 1); } + else if (vertical) { writePen(px - 1, py); writePen(px + 1, py); } + else if (motionX == motionY) { writePen(px - 1, py); writePen(px + 1, py); } + else { writePen(px, py - 1); writePen(px, py + 1); } + clearInk(px, py); + if (std::abs(motionX) >= std::abs(motionY)) writePen(px, py + (motionX ? motionX : 1)); + else writePen(px + (motionY ? motionY : 1), py); + }; + auto randomOffset = [&](bool wide) { return randomRange(wide ? 10 : 5) - (wide ? 5 : 2); }; + auto stampJitter = [&](bool wide) { + const int ax = std::clamp(previousPx + randomOffset(wide), 0, sppm::kWidth - 1); + const int ay = std::clamp(previousPy + randomOffset(wide), 0, sppm::kHeight - 1); + const int bx = std::clamp(px + randomOffset(wide), 0, sppm::kWidth - 1); + const int by = std::clamp(py + randomOffset(wide), 0, sppm::kHeight - 1); + drawLine(ax, ay, bx, by, true); + }; + auto sprayClass = [&]() { + const int d2 = motionX * motionX + motionY * motionY; + if (d2 < 4) return 6; + if (d2 < 8) return 5; + if (d2 < 12) return 4; + if (d2 < 24) return 3; + if (d2 < 48) return 2; + return 1; + }; + auto stampSpray = [&](bool heavy) { + const int cls = sprayClass(); + const int count = cls + (heavy ? 3 : 2); + const int spread = (7 - cls) + (heavy ? 5 : 2); + for (int i = 0; i < count; ++i) { + const int scale = heavy && (i & 1) ? 2 : 1; + const int ax = std::clamp(previousPx + (randomRange(spread * 2 + 1) - spread) * scale, 0, sppm::kWidth - 1); + const int ay = std::clamp(previousPy + (randomRange(spread * 2 + 1) - spread) * scale, 0, sppm::kHeight - 1); + const int bx = std::clamp(px + (randomRange(spread * 2 + 1) - spread) * scale, 0, sppm::kWidth - 1); + const int by = std::clamp(py + (randomRange(spread * 2 + 1) - spread) * scale, 0, sppm::kHeight - 1); + drawLine(ax, ay, bx, by, true); + } + }; + auto stampAirbrush = [&](bool large) { + static const std::array smallMask{{"..#..", ".###.", "#####", ".###.", "..#.."}}; + static const std::array largeMask{{ + "......###......", ".....#####.....", "...#########...", "..###########..", ".#############.", + "###############", "###############", "###############", "###############", "###############", + ".#############.", "..###########..", "...#########...", ".....#####.....", "......###......" + }}; + const int width = large ? 15 : 5; + const int height = large ? 15 : 5; + const int segmentMagnitude = std::max(std::abs(motionX), std::abs(motionY)); + const int base = large ? 72 : 98; + const int penalty = std::min(38, std::max(0, segmentMagnitude - 1) * (large ? 2 : 4)); + const int threshold = std::max(24, base - penalty); + for (int row = 0; row < height; ++row) for (int col = 0; col < width; ++col) { + const char mark = large ? largeMask[static_cast(row)][col] : smallMask[static_cast(row)][col]; + if (mark != '#' || static_cast(random16() & 0xffu) >= threshold) continue; + writePen(px + col - width / 2, py + row - height / 2); + } + }; + switch (penType) { + case 0: writePen(px, py); break; + case 1: stampDirectionAware(); break; + case 2: stampSquare(px, py, 1); break; + case 3: { + const int qx = px & ~1, qy = py & ~1; + if (squiggleAnchorX >= 0 && (squiggleAnchorX != qx || squiggleAnchorY != qy)) { + const int mx = ((squiggleAnchorX + qx) >> 1) & ~1; + const int my = ((squiggleAnchorY + qy) >> 1) & ~1; + writePen(mx, my); writePen(mx + 1, my); writePen(mx, my + 1); writePen(mx + 1, my + 1); + } + writePen(qx, qy); writePen(qx + 1, qy); writePen(qx, qy + 1); writePen(qx + 1, qy + 1); + squiggleAnchorX = qx; squiggleAnchorY = qy; + break; + } + case 4: stampDirectionalEqual(); break; + case 5: stampAirbrush(false); break; + case 6: if (motionX || motionY) stampJitter(false); else writePen(px, py); break; + case 7: if (motionX || motionY) stampSpray(false); else writePen(px, py); break; + case 8: stampSquare(px, py, 2); break; + case 9: stampSquare(px, py, 3); break; + case 10: stampSquare(px, py, 4); break; + case 11: { + const int d2 = motionX * motionX + motionY * motionY; + const int cls = d2 < 4 ? 3 : d2 < 32 ? 2 : 1; + if (cls == 1) writePen(px, py); + else if (cls == 2) stampDirectionAware(); + else stampSquare(px, py, 1); + break; + } + case 12: { + static const std::array outer{{"..###..", ".#####.", "#######", "#######", "#######", ".#####.", "..###.."}}; + static const std::array inner{{"..#..", ".###.", "#####", ".###.", "..#.."}}; + for (int y = 0; y < 7; ++y) for (int x = 0; x < 7; ++x) + if (outer[static_cast(y)][x] == '#') write(px + x - 3, py + y - 3); + const int holeX = ringPreviousX < 0 ? px : ringPreviousX; + const int holeY = ringPreviousY < 0 ? py : ringPreviousY; + for (int y = 0; y < 5; ++y) for (int x = 0; x < 5; ++x) + if (inner[static_cast(y)][x] == '#') clearInk(holeX + x - 2, holeY + y - 2); + ringPreviousX = px; ringPreviousY = py; + break; + } + case 13: stampAirbrush(true); break; + case 14: if (motionX || motionY) stampJitter(true); else writePen(px, py); break; + case 15: if (motionX || motionY) stampSpray(true); else writePen(px, py); break; + default: writePen(px, py); break; + } + } + return changed; +} + +void App::strokeAt(int sx, int sy, bool comment) { + const bool stabilise = !comment && penStabilisationEnabled && activeTool != Tool::Eraser; + if (stabilise && !stabilisedAfterReplay) { + if (penStabilisationAfter) { + if (afterStrokePoints.size() < 50000) afterStrokePoints.emplace_back(sx, sy); + return; + } + const float amount = penStabilisationStrength / 100.0f; + const float follow = 1.0f - amount * 0.88f; + if (!realtimeStrokeInitialized) { + realtimeStrokeX = static_cast(sx); + realtimeStrokeY = static_cast(sy); + realtimeStrokeInitialized = true; + } else { + realtimeStrokeX += (sx - realtimeStrokeX) * follow; + realtimeStrokeY += (sy - realtimeStrokeY) * follow; + } + sx = static_cast(std::lround(realtimeStrokeX)); + sy = static_cast(std::lround(realtimeStrokeY)); + } + std::vector* pixels = nullptr; + if (comment) pixels = &commentPixels; + else if (docLoaded && frame < doc.frames.size() && layer >= 0 && layer < 4) pixels = &doc.frames[frame].layers[layer]; + if (!pixels) return; + bool changed = false; + if (hasLastStroke) { + const int startX = lastStrokeX, startY = lastStrokeY; + const int dx = sx - startX, dy = sy - startY; + const int count = std::max(std::abs(dx), std::abs(dy)); + for (int i = 1; i <= count; ++i) { + const int x = startX + dx * i / count; + const int y = startY + dy * i / count; + changed |= paintPixel(x, y, *pixels); + lastStrokeX = x; + lastStrokeY = y; + } + } else { + squiggleAnchorX = squiggleAnchorY = ringPreviousX = ringPreviousY = -1; + changed = paintPixel(sx, sy, *pixels); + lastStrokeX = sx; + lastStrokeY = sy; + hasLastStroke = true; + } + if (changed) { + if (comment) commentDirty = true; + else markDirty(); + } +} + +void App::finishAfterStroke() { + if (!penStabilisationEnabled || !penStabilisationAfter || afterStrokePoints.empty() || + !docLoaded || frame >= doc.frames.size() || layer < 0 || layer >= 4) { + afterStrokePoints.clear(); + realtimeStrokeInitialized = false; + return; + } + const std::size_t strokeFrame = frame; + const int strokeLayer = layer; + auto& pixels = doc.frames[strokeFrame].layers[static_cast(strokeLayer)]; + if (!undoStack.empty() && undoStack.back().frame == strokeFrame && undoStack.back().layer == strokeLayer && + undoStack.back().pixels.size() == pixels.size()) { + pixels = undoStack.back().pixels; + markDirty(); + } + const auto points = std::move(afterStrokePoints); + afterStrokePoints.clear(); + const int radius = std::clamp(static_cast(std::lround(penStabilisationStrength * 0.06f)), 0, 6); + stabilisedAfterReplay = true; + hasLastStroke = false; + for (std::size_t i = 0; i < points.size(); ++i) { + int x = 0, y = 0, count = 0; + const std::size_t from = i > static_cast(radius) ? i - static_cast(radius) : 0; + const std::size_t to = std::min(points.size() - 1, i + static_cast(radius)); + for (std::size_t j = from; j <= to; ++j) { + x += points[j].first; + y += points[j].second; + ++count; + } + strokeAt(x / count, y / count); + } + stabilisedAfterReplay = false; + hasLastStroke = false; + realtimeStrokeInitialized = false; +} + +} // namespace diff --git a/source/sppm.cpp b/source/sppm.cpp new file mode 100644 index 0000000..4cee588 --- /dev/null +++ b/source/sppm.cpp @@ -0,0 +1,1055 @@ +#include "sppm.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __SWITCH__ +#include +#endif + +#ifdef _WIN32 +#define NOMINMAX +#include +#endif + +extern "C" { +int crypto_sign_ed25519_tweet(unsigned char*, unsigned long long*, const unsigned char*, unsigned long long, const unsigned char*); +int crypto_sign_ed25519_tweet_open(unsigned char*, unsigned long long*, const unsigned char*, unsigned long long, const unsigned char*); +int crypto_sign_ed25519_tweet_keypair(unsigned char*, unsigned char*); +int crypto_secretbox_xsalsa20poly1305_tweet(unsigned char*, const unsigned char*, unsigned long long, const unsigned char*, const unsigned char*); +int crypto_secretbox_xsalsa20poly1305_tweet_open(unsigned char*, const unsigned char*, unsigned long long, const unsigned char*, const unsigned char*); + +void randombytes(unsigned char* out, unsigned long long length) { +#ifdef __SWITCH__ + if (R_FAILED(csrngGetRandomBytes(out, static_cast(length)))) std::abort(); +#else + std::random_device random; + for (unsigned long long i = 0; i < length; ++i) out[i] = static_cast(random()); +#endif +} +} + +namespace sppm { +namespace { + +constexpr std::uint16_t kVersion = 3; +constexpr std::uint16_t kLegacyVersion = 1; +constexpr std::uint16_t kHeaderBytes = 36; +constexpr std::uint64_t kMaxFileBytes = 512ULL * 1024ULL * 1024ULL; +constexpr std::size_t kMaxAudioSamples = 32ULL * 1024ULL * 1024ULL; +constexpr std::size_t kMaxLayerRleBytes = kPixelsPerLayer * 2; +constexpr std::size_t kSignatureBytes = 36; +constexpr std::size_t kPublicSignatureBytes = 4 + 3 * (32 + 16) + 64; +constexpr char kNullId[] = "0000000000000000"; + +std::string upperId(std::string_view id) { + std::string out(id); + for (char& c : out) { + if (c >= 'a' && c <= 'f') c = static_cast(c - 'a' + 'A'); + } + return out; +} + +bool sameId(std::string_view a, std::string_view b) { + return upperId(a) == upperId(b); +} + +bool validUtf8(std::string_view text) { + for (std::size_t i = 0; i < text.size();) { + const auto first = static_cast(text[i]); + if (first < 0x80) { ++i; continue; } + std::size_t length; + if (first >= 0xC2 && first <= 0xDF) length = 2; + else if (first >= 0xE0 && first <= 0xEF) length = 3; + else if (first >= 0xF0 && first <= 0xF4) length = 4; + else return false; + if (length > text.size() - i) return false; + const auto second = static_cast(text[i + 1]); + if (second < 0x80 || second > 0xBF || + (first == 0xE0 && second < 0xA0) || + (first == 0xED && second > 0x9F) || + (first == 0xF0 && second < 0x90) || + (first == 0xF4 && second > 0x8F)) return false; + for (std::size_t j = 2; j < length; ++j) { + const auto continuation = static_cast(text[i + j]); + if (continuation < 0x80 || continuation > 0xBF) return false; + } + i += length; + } + return true; +} + +std::uint32_t crc32(const std::uint8_t* bytes, std::size_t length) { + static const auto table = [] { + std::array entries{}; + for (std::uint32_t i = 0; i < entries.size(); ++i) { + std::uint32_t c = i; + for (int j = 0; j < 8; ++j) c = (c >> 1) ^ ((c & 1) ? 0xEDB88320U : 0U); + entries[i] = c; + } + return entries; + }(); + std::uint32_t c = 0xFFFFFFFFU; + for (std::size_t i = 0; i < length; ++i) c = table[(c ^ bytes[i]) & 0xFFU] ^ (c >> 8); + return c ^ 0xFFFFFFFFU; +} + +class Writer { +public: + std::vector bytes; + void u8(std::uint8_t v) { bytes.push_back(v); } + void u16(std::uint16_t v) { u8(static_cast(v)); u8(static_cast(v >> 8)); } + void u32(std::uint32_t v) { for (int i = 0; i < 4; ++i) u8(static_cast(v >> (i * 8))); } + void u64(std::uint64_t v) { for (int i = 0; i < 8; ++i) u8(static_cast(v >> (i * 8))); } + void raw(const std::uint8_t* p, std::size_t n) { if (n) bytes.insert(bytes.end(), p, p + n); } + void text(std::string_view value) { u16(static_cast(value.size())); raw(reinterpret_cast(value.data()), value.size()); } + void id(std::string_view value) { + const std::string normal = upperId(value.empty() ? std::string_view(kNullId) : value); + raw(reinterpret_cast(normal.data()), normal.size()); + } + void setU32(std::size_t at, std::uint32_t v) { for (int i = 0; i < 4; ++i) bytes[at + i] = static_cast(v >> (i * 8)); } + void setU64(std::size_t at, std::uint64_t v) { for (int i = 0; i < 8; ++i) bytes[at + i] = static_cast(v >> (i * 8)); } +}; + +class Reader { +public: + explicit Reader(const std::vector& b) : bytes(b) {} + const std::vector& bytes; + std::size_t at = 0; + bool take(std::size_t n, const std::uint8_t*& p) { + if (n > bytes.size() - at) return false; + p = bytes.data() + at; + at += n; + return true; + } + bool u8(std::uint8_t& v) { const std::uint8_t* p; if (!take(1, p)) return false; v = *p; return true; } + bool u16(std::uint16_t& v) { const std::uint8_t* p; if (!take(2, p)) return false; v = static_cast(p[0] | (p[1] << 8)); return true; } + bool u32(std::uint32_t& v) { const std::uint8_t* p; if (!take(4, p)) return false; v = 0; for (int i = 0; i < 4; ++i) v |= static_cast(p[i]) << (i * 8); return true; } + bool u64(std::uint64_t& v) { const std::uint8_t* p; if (!take(8, p)) return false; v = 0; for (int i = 0; i < 8; ++i) v |= static_cast(p[i]) << (i * 8); return true; } + bool text(std::string& out, std::size_t maxLength) { + std::uint16_t n; + const std::uint8_t* p; + if (!u16(n) || n > maxLength || !take(n, p)) return false; + out.assign(reinterpret_cast(p), n); + return out.find('\0') == std::string::npos && validUtf8(out); + } + bool id(std::string& out, bool nullable) { + const std::uint8_t* p; + if (!take(16, p)) return false; + out.assign(reinterpret_cast(p), 16); + if (nullable && out == kNullId) { out.clear(); return true; } + if (!isValidFsid(out) || out == kNullId) return false; + out = upperId(out); + return true; + } +}; + +std::vector encodeLayer(const std::vector& pixels) { + std::vector out; + out.reserve(pixels.size() / 2); + std::size_t at = 0; + while (at < pixels.size()) { + std::size_t run = 1; + while (run < 128 && at + run < pixels.size() && pixels[at + run] == pixels[at]) ++run; + if (run >= 3) { + out.push_back(static_cast(0x80U | (run - 1))); + out.push_back(pixels[at]); + at += run; + continue; + } + const std::size_t start = at; + at += run; + while (at - start < 128 && at < pixels.size()) { + run = 1; + while (run < 3 && at + run < pixels.size() && pixels[at + run] == pixels[at]) ++run; + if (run >= 3 || at + run - start > 128) break; + at += run; + } + out.push_back(static_cast(at - start - 1)); + out.insert(out.end(), pixels.begin() + static_cast(start), + pixels.begin() + static_cast(at)); + } + return out; +} + +bool decodeLayer(const std::uint8_t* p, std::size_t n, std::vector& out) { + out.clear(); + out.reserve(kPixelsPerLayer); + std::size_t at = 0; + while (at < n) { + const std::uint8_t command = p[at++]; + const std::size_t count = static_cast(command & 0x7FU) + 1; + if (count > kPixelsPerLayer - out.size()) return false; + if (command & 0x80U) { + if (at == n) return false; + out.insert(out.end(), count, p[at++]); + } else { + if (count > n - at) return false; + out.insert(out.end(), p + at, p + at + count); + at += count; + } + } + return out.size() == kPixelsPerLayer; +} + +std::uint32_t rightRotate(std::uint32_t x, unsigned n) { return (x >> n) | (x << (32 - n)); } + +class Sha256 { +public: + void update(const std::uint8_t* p, std::size_t n) { + total += n; + while (n) { + const std::size_t step = std::min(n, block.size() - used); + std::memcpy(block.data() + used, p, step); + used += step; + p += step; + n -= step; + if (used == block.size()) { transform(); used = 0; } + } + } + std::array finish() { + const std::uint64_t bitLength = static_cast(total) * 8; + const std::uint8_t marker = 0x80; + update(&marker, 1); + const std::uint8_t zero = 0; + while (used != 56) update(&zero, 1); + std::uint8_t length[8]; + for (int i = 0; i < 8; ++i) length[i] = static_cast(bitLength >> ((7 - i) * 8)); + update(length, 8); + std::array result{}; + for (int i = 0; i < 8; ++i) { + result[i * 4] = static_cast(state[i] >> 24); + result[i * 4 + 1] = static_cast(state[i] >> 16); + result[i * 4 + 2] = static_cast(state[i] >> 8); + result[i * 4 + 3] = static_cast(state[i]); + } + return result; + } +private: + static constexpr std::uint32_t K[64] = { + 0x428a2f98U,0x71374491U,0xb5c0fbcfU,0xe9b5dba5U,0x3956c25bU,0x59f111f1U,0x923f82a4U,0xab1c5ed5U, + 0xd807aa98U,0x12835b01U,0x243185beU,0x550c7dc3U,0x72be5d74U,0x80deb1feU,0x9bdc06a7U,0xc19bf174U, + 0xe49b69c1U,0xefbe4786U,0x0fc19dc6U,0x240ca1ccU,0x2de92c6fU,0x4a7484aaU,0x5cb0a9dcU,0x76f988daU, + 0x983e5152U,0xa831c66dU,0xb00327c8U,0xbf597fc7U,0xc6e00bf3U,0xd5a79147U,0x06ca6351U,0x14292967U, + 0x27b70a85U,0x2e1b2138U,0x4d2c6dfcU,0x53380d13U,0x650a7354U,0x766a0abbU,0x81c2c92eU,0x92722c85U, + 0xa2bfe8a1U,0xa81a664bU,0xc24b8b70U,0xc76c51a3U,0xd192e819U,0xd6990624U,0xf40e3585U,0x106aa070U, + 0x19a4c116U,0x1e376c08U,0x2748774cU,0x34b0bcb5U,0x391c0cb3U,0x4ed8aa4aU,0x5b9cca4fU,0x682e6ff3U, + 0x748f82eeU,0x78a5636fU,0x84c87814U,0x8cc70208U,0x90befffaU,0xa4506cebU,0xbef9a3f7U,0xc67178f2U + }; + std::array state{0x6a09e667U,0xbb67ae85U,0x3c6ef372U,0xa54ff53aU,0x510e527fU,0x9b05688cU,0x1f83d9abU,0x5be0cd19U}; + std::array block{}; + std::size_t used = 0; + std::size_t total = 0; + void transform() { + std::uint32_t w[64]; + for (int i = 0; i < 16; ++i) { + const int j = i * 4; + w[i] = (static_cast(block[j]) << 24) | (static_cast(block[j+1]) << 16) | + (static_cast(block[j+2]) << 8) | block[j+3]; + } + for (int i = 16; i < 64; ++i) { + const std::uint32_t s0 = rightRotate(w[i-15], 7) ^ rightRotate(w[i-15], 18) ^ (w[i-15] >> 3); + const std::uint32_t s1 = rightRotate(w[i-2], 17) ^ rightRotate(w[i-2], 19) ^ (w[i-2] >> 10); + w[i] = w[i-16] + s0 + w[i-7] + s1; + } + std::uint32_t a=state[0], b=state[1], c=state[2], d=state[3], e=state[4], f=state[5], g=state[6], h=state[7]; + for (int i = 0; i < 64; ++i) { + const std::uint32_t s1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25); + const std::uint32_t choose = (e & f) ^ (~e & g); + const std::uint32_t t1 = h + s1 + choose + K[i] + w[i]; + const std::uint32_t s0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22); + const std::uint32_t majority = (a & b) ^ (a & c) ^ (b & c); + const std::uint32_t t2 = s0 + majority; + h=g; g=f; f=e; e=d+t1; d=c; c=b; b=a; a=t1+t2; + } + state[0]+=a; state[1]+=b; state[2]+=c; state[3]+=d; + state[4]+=e; state[5]+=f; state[6]+=g; state[7]+=h; + } +}; + +std::array hmacSha256(const std::array& secret, + const std::uint8_t* message, std::size_t length) { + std::array innerKey{}, outerKey{}; + for (std::size_t i = 0; i < 64; ++i) { + const std::uint8_t key = i < secret.size() ? secret[i] : 0; + innerKey[i] = key ^ 0x36U; + outerKey[i] = key ^ 0x5cU; + } + Sha256 inner; + inner.update(innerKey.data(), innerKey.size()); + inner.update(message, length); + const auto middle = inner.finish(); + Sha256 outer; + outer.update(outerKey.data(), outerKey.size()); + outer.update(middle.data(), middle.size()); + return outer.finish(); +} + +bool commitBytes(const std::string& path, const std::vector& bytes, + std::string& error) { + if (path.empty()) { error = "empty save path"; return false; } + static std::atomic sequence{0}; + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + const std::uint64_t serial = sequence.fetch_add(1); + std::filesystem::path target(path); + std::filesystem::path temporary = target; + temporary += ".tmp-" + std::to_string(stamp) + "-" + std::to_string(serial); + { + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + if (!output) { error = "could not create temporary save file"; return false; } + output.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + output.flush(); + if (!output) { + output.close(); + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + error = "could not write complete temporary save file"; + return false; + } + } +#ifdef _WIN32 + if (!MoveFileExW(temporary.c_str(), target.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + error = "could not replace save file"; + return false; + } +#else + std::error_code ec; + std::filesystem::rename(temporary, target, ec); + if (ec) { + // Some SD/FAT rename implementations refuse to replace an existing + // file. Keep the old note recoverable while installing the new one. + std::error_code existsError; + const bool targetExists = std::filesystem::exists(target, existsError); + if (!targetExists || existsError) { + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + error = "could not replace save file: " + ec.message(); + return false; + } + std::filesystem::path backup = target; + backup += ".bak-" + std::to_string(stamp) + "-" + std::to_string(serial); + std::error_code backupError; + std::filesystem::rename(target, backup, backupError); + if (backupError) { + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + error = "could not preserve previous save file: " + backupError.message(); + return false; + } + ec.clear(); + std::filesystem::rename(temporary, target, ec); + if (ec) { + std::error_code restoreError; + std::filesystem::rename(backup, target, restoreError); + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + error = "could not install new save file: " + ec.message(); + if (restoreError) error += "; previous save preserved at " + backup.string(); + return false; + } + std::error_code ignored; + std::filesystem::remove(backup, ignored); + } +#endif + error.clear(); + return true; +} + +} // namespace + +Palette defaultPalette() { + Palette palette{}; + palette[0] = {0, 0, 0, 0}; + constexpr std::array initial = {{{255,255,255,255}, {0,0,0,255}, {255,52,63,255}, + {255,119,28,255}, {255,221,0,255}, {239,68,133,255}, {0,172,102,255}, + {0,214,231,255}, {21,82,151,255}, {132,66,183,255}, {132,66,183,255}, + {239,68,133,255}, {144,94,62,255}, {115,122,131,255}, {210,219,230,255}}}; + std::copy(initial.begin(), initial.end(), palette.begin() + 1); + for (std::size_t i = 16; i < palette.size(); ++i) { + const std::uint8_t shade = static_cast((i - 16) * 255 / (palette.size() - 17)); + palette[i] = {shade, shade, shade, 255}; + } + return palette; +} + +Frame::Frame() { + for (auto& layer : layers) layer.assign(kPixelsPerLayer, 0); +} + +bool isValidFsid(std::string_view fsid) { + if (fsid.size() != 16) return false; + for (char c : fsid) { + if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) return false; + } + return fsid != kNullId; +} + +std::string deriveFsid(const std::array& secret, + const std::array& nonce, + std::string_view domain) { + constexpr char prefix[] = "FlipnoteNX-FSID-v1"; + std::vector message; + message.insert(message.end(), prefix, prefix + sizeof(prefix)); // includes separator NUL + message.insert(message.end(), domain.begin(), domain.end()); + message.push_back(0); + message.insert(message.end(), nonce.begin(), nonce.end()); + const auto digest = hmacSha256(secret, message.data(), message.size()); + constexpr char hex[] = "0123456789ABCDEF"; + std::string result(16, '0'); + for (int i = 0; i < 8; ++i) { + result[i * 2] = hex[digest[i] >> 4]; + result[i * 2 + 1] = hex[digest[i] & 15]; + } + return result; +} + +bool hasPublicIdentity(const PublicIdentity& identity) { + return std::any_of(identity.key.begin(), identity.key.end(), [](std::uint8_t value) { return value != 0; }) && + std::any_of(identity.deviceTag.begin(), identity.deviceTag.end(), [](std::uint8_t value) { return value != 0; }); +} + +std::array deriveDeviceTag(const std::vector& deviceMaterial) { + constexpr char domain[] = "FlipnoteNX-device-tag-v1"; + Sha256 hash; + hash.update(reinterpret_cast(domain), sizeof(domain)); + if (!deviceMaterial.empty()) hash.update(deviceMaterial.data(), deviceMaterial.size()); + const auto digest = hash.finish(); + std::array tag{}; + std::copy_n(digest.begin(), tag.size(), tag.begin()); + return tag; +} + +std::array deriveDeviceWrapKey(const std::vector& deviceMaterial, + const std::array& salt) { + constexpr char domain[] = "FlipnoteNX-identity-wrap-v1"; + Sha256 materialHash; + materialHash.update(reinterpret_cast(domain), sizeof(domain)); + if (!deviceMaterial.empty()) materialHash.update(deviceMaterial.data(), deviceMaterial.size()); + const auto key = materialHash.finish(); + std::vector context(domain, domain + sizeof(domain)); + context.insert(context.end(), salt.begin(), salt.end()); + return hmacSha256(key, context.data(), context.size()); +} + +std::string deriveFsid(const PublicIdentity& identity) { + if (!hasPublicIdentity(identity)) return {}; + constexpr char domain[] = "FlipnoteNX-public-fsid-v2"; + Sha256 hash; + hash.update(reinterpret_cast(domain), sizeof(domain)); + hash.update(identity.key.data(), identity.key.size()); + hash.update(identity.deviceTag.data(), identity.deviceTag.size()); + const auto digest = hash.finish(); + constexpr char hex[] = "0123456789ABCDEF"; + std::string result(16, '0'); + for (int i = 0; i < 8; ++i) { + result[i * 2] = hex[digest[static_cast(i)] >> 4]; + result[i * 2 + 1] = hex[digest[static_cast(i)] & 15]; + } + return result; +} + +std::string identityFingerprint(const PublicIdentity& identity) { + if (!hasPublicIdentity(identity)) return {}; + constexpr char domain[] = "FlipnoteNX-gallery-key-v2"; + Sha256 hash; + hash.update(reinterpret_cast(domain), sizeof(domain)); + hash.update(identity.key.data(), identity.key.size()); + const auto digest = hash.finish(); + constexpr char hex[] = "0123456789abcdef"; + std::string result; + result.reserve(digest.size() * 2); + for (std::uint8_t byte : digest) { + result.push_back(hex[byte >> 4]); + result.push_back(hex[byte & 15]); + } + return result; +} + +bool createSigningIdentity(const std::array& deviceTag, + SigningIdentity& identity, std::string& error) { + if (!std::any_of(deviceTag.begin(), deviceTag.end(), [](std::uint8_t value) { return value != 0; }) || + crypto_sign_ed25519_tweet_keypair(identity.publicIdentity.key.data(), identity.secretKey.data()) != 0) { + error = "could not create Ed25519 signing identity"; + return false; + } + identity.publicIdentity.deviceTag = deviceTag; + error.clear(); + return true; +} + +bool sealSigningSecret(const SigningIdentity& identity, + const std::array& wrapKey, + const std::array& nonce, + std::array& sealed, std::string& error) { + std::array input{}; + std::copy(identity.secretKey.begin(), identity.secretKey.end(), input.begin() + 32); + std::copy(identity.legacyHmacKey.begin(), identity.legacyHmacKey.end(), input.begin() + 96); + if (crypto_secretbox_xsalsa20poly1305_tweet(sealed.data(), input.data(), input.size(), nonce.data(), wrapKey.data()) != 0) { + error = "could not encrypt signing key"; + return false; + } + error.clear(); + return true; +} + +bool unsealSigningSecret(const std::array& sealed, + const std::array& wrapKey, + const std::array& nonce, + SigningIdentity& identity, std::string& error) { + std::array output{}; + if (crypto_secretbox_xsalsa20poly1305_tweet_open(output.data(), sealed.data(), sealed.size(), nonce.data(), wrapKey.data()) != 0 || + std::any_of(output.begin(), output.begin() + 32, [](std::uint8_t value) { return value != 0; })) { + error = "identity key cannot be opened on this console"; + return false; + } + std::copy(output.begin() + 32, output.begin() + 96, identity.secretKey.begin()); + std::copy(output.begin() + 96, output.end(), identity.legacyHmacKey.begin()); + error.clear(); + return true; +} + +double frameRate(const Metadata& metadata) { + constexpr std::array rates{{0.4985, 1.0, 2.0, 4.0, 5.9972, 11.986, 19.9459, 29.9275}}; + if (metadata.speed >= 1 && metadata.speed <= rates.size()) return rates[metadata.speed - 1]; + return std::max(1, static_cast(metadata.fps)); +} + +bool validate(const Document& document, std::string& error) { + const Metadata& m = document.metadata; + if (document.frames.empty() || document.frames.size() > kMaxFrames) { + error = "frame count must be between 1 and 9999"; return false; + } + if (document.audioTracks.size() > kMaxAudioTracks) { + error = "too many audio tracks"; return false; + } + if (m.title.size() > 256 || m.description.size() > 4096 || + m.authorName.size() > 128 || m.originalAuthorName.size() > 128 || + m.lastEditorName.size() > 128) { + error = "metadata text is too long"; return false; + } + for (const std::string* text : {&m.title, &m.description, &m.authorName, + &m.originalAuthorName, &m.lastEditorName}) { + if (text->find('\0') != std::string::npos || !validUtf8(*text)) { + error = "metadata text is not valid UTF-8"; return false; + } + } + if (!isValidFsid(m.authorFsid) || !isValidFsid(m.originalAuthorFsid) || + !isValidFsid(m.lastEditorFsid) || !isValidFsid(m.currentId) || + !isValidFsid(m.rootId) || (!m.parentId.empty() && !isValidFsid(m.parentId))) { + error = "one or more IDs are not 16 hexadecimal characters"; return false; + } + const auto matchesIdentity = [](const PublicIdentity& identity, const std::string& fsid) { + return !hasPublicIdentity(identity) || sameId(deriveFsid(identity), fsid); + }; + if (!matchesIdentity(m.authorIdentity, m.authorFsid) || + !matchesIdentity(m.originalAuthorIdentity, m.originalAuthorFsid) || + !matchesIdentity(m.lastEditorIdentity, m.lastEditorFsid)) { + error = "creator FSID does not match its public identity"; return false; + } + if (m.parentId.empty()) { + if (!sameId(m.currentId, m.rootId) || !sameId(m.authorFsid, m.originalAuthorFsid)) { + error = "an original Switchnote must have its own root ID and original author"; return false; + } + } else if (sameId(m.parentId, m.currentId) || sameId(m.rootId, m.currentId)) { + error = "a spin-off must have a new ID distinct from its parent and root"; return false; + } + if (m.fps < 1 || m.fps > 30 || m.speed > 8 || m.coverFrame >= document.frames.size() || + m.createdUnixSeconds > m.modifiedUnixSeconds) { + error = "invalid playback or date metadata"; return false; + } + if (document.palette[0].a != 0) { + error = "palette index 0 must be transparent"; return false; + } + for (const Frame& frame : document.frames) { + for (const auto& layer : frame.layers) { + if (layer.size() != kPixelsPerLayer) { + error = "a layer has the wrong pixel count"; return false; + } + } + } + std::size_t audioSamples = 0; + for (std::size_t i = 0; i < document.audioTracks.size(); ++i) { + const AudioTrack& track = document.audioTracks[i]; + if (track.name.size() > 64 || track.name.find('\0') != std::string::npos || + !validUtf8(track.name) || + track.sampleRate < 8000 || track.sampleRate > 48000 || + (i == 0 && track.referenceFps != 0 && (track.referenceFps < 1 || track.referenceFps > 30)) || + (i == 0 && track.referenceSpeed > 8) || + (i == 0 && track.referenceSpeed != 0 && track.referenceFps != 0) || + (i > 0 && (track.referenceFps != 0 || track.referenceSpeed != 0)) || + (track.channels != 1 && track.channels != 2) || + track.pcm.size() % track.channels != 0 || + track.pcm.size() > kMaxAudioSamples - audioSamples || + track.frameTriggers.size() > document.frames.size() || + (i == 0 && !track.frameTriggers.empty())) { + error = "invalid audio track"; return false; + } + std::uint16_t previous = 0; + for (std::size_t cue = 0; cue < track.frameTriggers.size(); ++cue) { + const std::uint16_t frame = track.frameTriggers[cue]; + if (frame >= document.frames.size() || (cue != 0 && frame <= previous)) { + error = "audio frame triggers must be sorted, unique and in range"; return false; + } + previous = frame; + } + audioSamples += track.pcm.size(); + } + error.clear(); + return true; +} + +bool serialize(const Document& document, std::vector& bytes, + std::string& error) { + if (!validate(document, error)) return false; + Writer w; + w.bytes.reserve(kHeaderBytes + kPaletteSize * 4 + document.frames.size() * 256); + w.raw(reinterpret_cast("SPPM"), 4); + w.u16(kVersion); + w.u16(kHeaderBytes); + w.u64(0); // total size, patched below + w.u32(0); // CRC-32 of everything after the header + w.u16(kWidth); + w.u16(kHeight); + w.u8(static_cast(kLayerCount)); + w.u16(static_cast(kPaletteSize)); + w.u8(document.metadata.fps); + w.u8(static_cast((document.metadata.locked ? 1 : 0) | + (document.metadata.allowSpinoffs ? 2 : 0) | + (document.metadata.bgmFollowsSpeed ? 4 : 0) | + (document.metadata.loopPlayback ? 0 : 8))); + w.u32(static_cast(document.frames.size())); + w.u8(static_cast(document.audioTracks.size())); + w.u16(document.metadata.speed); // v3 playback speed index; 0 retains legacy fps behavior + if (w.bytes.size() != kHeaderBytes) { error = "internal header size error"; return false; } + + for (const Color& c : document.palette) { w.u8(c.r); w.u8(c.g); w.u8(c.b); w.u8(c.a); } + const Metadata& m = document.metadata; + w.text(m.title); w.text(m.description); w.text(m.authorName); + w.text(m.originalAuthorName); w.text(m.lastEditorName); + w.id(m.authorFsid); w.id(m.originalAuthorFsid); w.id(m.lastEditorFsid); + w.id(m.currentId); w.id(m.rootId); w.id(m.parentId); + w.u64(m.createdUnixSeconds); w.u64(m.modifiedUnixSeconds); w.u32(m.coverFrame); + + for (const Frame& frame : document.frames) { + for (const auto& layer : frame.layers) { + const std::vector encoded = encodeLayer(layer); + if (encoded.size() > kMaxLayerRleBytes) { error = "layer compression exceeded limit"; return false; } + w.u8(1); // RLE codec + w.u32(static_cast(encoded.size())); + w.u32(crc32(encoded.data(), encoded.size())); + w.raw(encoded.data(), encoded.size()); + } + } + for (std::size_t i = 0; i < document.audioTracks.size(); ++i) { + const AudioTrack& track = document.audioTracks[i]; + w.u8(static_cast(i)); + w.u8(track.channels); + w.u8(track.referenceSpeed ? track.referenceSpeed : track.referenceFps); + w.u32(track.sampleRate); + w.text(track.name); + w.u16(static_cast(track.frameTriggers.size())); + for (std::uint16_t frame : track.frameTriggers) w.u16(frame); + w.u32(static_cast(track.pcm.size())); + std::vector pcmBytes; + pcmBytes.reserve(track.pcm.size() * 2); + for (std::int16_t sample : track.pcm) { + const auto bits = static_cast(sample); + pcmBytes.push_back(static_cast(bits)); + pcmBytes.push_back(static_cast(bits >> 8)); + } + w.u32(crc32(pcmBytes.data(), pcmBytes.size())); + w.raw(pcmBytes.data(), pcmBytes.size()); + } + if (w.bytes.size() > kMaxFileBytes) { error = "file is too large"; return false; } + w.setU64(8, w.bytes.size()); + w.setU32(16, crc32(w.bytes.data() + kHeaderBytes, w.bytes.size() - kHeaderBytes)); + bytes.swap(w.bytes); + error.clear(); + return true; +} + +bool deserialize(const std::vector& bytes, Document& document, + std::string& error) { + if (bytes.size() < kHeaderBytes || bytes.size() > kMaxFileBytes) { + error = "file size is outside supported bounds"; return false; + } + Reader r(bytes); + const std::uint8_t* magic; + std::uint16_t version, headerBytes, width, height, paletteCount, reserved; + std::uint64_t totalBytes; + std::uint32_t payloadCrc, frameCount; + std::uint8_t layerCount, fps, flags, audioCount; + if (!r.take(4, magic) || std::memcmp(magic, "SPPM", 4) != 0 || + !r.u16(version) || !r.u16(headerBytes) || !r.u64(totalBytes) || + !r.u32(payloadCrc) || !r.u16(width) || !r.u16(height) || + !r.u8(layerCount) || !r.u16(paletteCount) || !r.u8(fps) || + !r.u8(flags) || !r.u32(frameCount) || !r.u8(audioCount) || !r.u16(reserved)) { + error = "invalid or truncated SPPM header"; return false; + } + if ((version < 1 || version > kVersion) || headerBytes != kHeaderBytes || totalBytes != bytes.size() || + width != kWidth || height != kHeight || layerCount != kLayerCount || + paletteCount != kPaletteSize || fps < 1 || fps > 30 || + (version == kLegacyVersion ? (flags & ~3U) != 0 : (flags & ~15U) != 0) || + frameCount == 0 || frameCount > kMaxFrames || audioCount > kMaxAudioTracks || + (version < 3 ? reserved != 0 : reserved > 8) || + crc32(bytes.data() + kHeaderBytes, bytes.size() - kHeaderBytes) != payloadCrc) { + error = "unsupported SPPM layout or failed file CRC"; return false; + } + Document parsed; + parsed.metadata.fps = fps; + parsed.metadata.speed = version >= 3 ? static_cast(reserved) : 0; + parsed.metadata.locked = (flags & 1U) != 0; + parsed.metadata.allowSpinoffs = (flags & 2U) != 0; + parsed.metadata.bgmFollowsSpeed = version == kLegacyVersion || (flags & 4U) != 0; + parsed.metadata.loopPlayback = version == kLegacyVersion || (flags & 8U) == 0; + for (Color& c : parsed.palette) { + if (!r.u8(c.r) || !r.u8(c.g) || !r.u8(c.b) || !r.u8(c.a)) { + error = "truncated palette"; return false; + } + // Migrate the two original default swatches to the requested SPPM + // palette: dark navy becomes purple and lime becomes pink. Palette + // indices remain stable, so existing drawings keep their structure. + if (c.a && c.r == 26 && c.g == 40 && c.b == 72) c = {132, 66, 183, c.a}; + else if (c.a && c.r == 92 && c.g == 224 && c.b == 36) c = {239, 68, 133, c.a}; + } + Metadata& m = parsed.metadata; + if (!r.text(m.title, 256) || !r.text(m.description, 4096) || + !r.text(m.authorName, 128) || !r.text(m.originalAuthorName, 128) || + !r.text(m.lastEditorName, 128) || !r.id(m.authorFsid, false) || + !r.id(m.originalAuthorFsid, false) || !r.id(m.lastEditorFsid, false) || + !r.id(m.currentId, false) || !r.id(m.rootId, false) || + !r.id(m.parentId, true) || !r.u64(m.createdUnixSeconds) || + !r.u64(m.modifiedUnixSeconds) || !r.u32(m.coverFrame)) { + error = "invalid or truncated metadata"; return false; + } + parsed.frames.reserve(frameCount); + for (std::uint32_t frameIndex = 0; frameIndex < frameCount; ++frameIndex) { + Frame frame; + for (auto& layer : frame.layers) { + std::uint8_t codec; + std::uint32_t length, layerCrc; + const std::uint8_t* compressed; + if (!r.u8(codec) || !r.u32(length) || !r.u32(layerCrc) || codec != 1 || + length == 0 || length > kMaxLayerRleBytes || !r.take(length, compressed) || + crc32(compressed, length) != layerCrc || !decodeLayer(compressed, length, layer)) { + error = "invalid frame layer or failed layer CRC"; return false; + } + } + parsed.frames.push_back(std::move(frame)); + } + std::size_t totalSamples = 0; + for (std::uint8_t i = 0; i < audioCount; ++i) { + AudioTrack track; + std::uint8_t trackIndex; + std::uint16_t triggerCount; + std::uint32_t sampleCount, pcmCrc; + if (!r.u8(trackIndex) || !r.u8(track.channels) || + (version >= 2 && !r.u8(track.referenceFps)) || !r.u32(track.sampleRate) || + !r.text(track.name, 64) || !r.u16(triggerCount) || + trackIndex != i || triggerCount > frameCount || (i == 0 && triggerCount != 0)) { + error = "invalid audio metadata"; return false; + } + if (version == kLegacyVersion && i == 0) track.referenceFps = parsed.metadata.fps; + if (version >= 3 && parsed.metadata.speed != 0) { + if (i == 0) { track.referenceSpeed = track.referenceFps; track.referenceFps = 0; } + else if (track.referenceFps != 0) { error = "invalid sound-effect speed metadata"; return false; } + } + track.frameTriggers.resize(triggerCount); + for (std::uint16_t& frame : track.frameTriggers) { + if (!r.u16(frame)) { error = "truncated audio triggers"; return false; } + } + if (!r.u32(sampleCount) || !r.u32(pcmCrc) || + sampleCount > kMaxAudioSamples - totalSamples || + (track.channels != 1 && track.channels != 2) || + sampleCount % track.channels != 0 || + track.sampleRate < 8000 || track.sampleRate > 48000) { + error = "invalid audio metadata"; return false; + } + const std::uint8_t* pcmBytes; + if (!r.take(static_cast(sampleCount) * 2, pcmBytes) || + crc32(pcmBytes, static_cast(sampleCount) * 2) != pcmCrc) { + error = "truncated audio or failed audio CRC"; return false; + } + track.pcm.resize(sampleCount); + for (std::size_t n = 0; n < sampleCount; ++n) { + const std::uint16_t bits = static_cast(pcmBytes[n * 2] | (pcmBytes[n * 2 + 1] << 8)); + track.pcm[n] = static_cast(bits); + } + totalSamples += sampleCount; + parsed.audioTracks.push_back(std::move(track)); + } + if (r.at != bytes.size()) { error = "unexpected trailing data"; return false; } + if (!validate(parsed, error)) return false; + document = std::move(parsed); + error.clear(); + return true; +} + +bool loadFile(const std::string& path, Document& document, std::string& error) { + std::ifstream input(std::filesystem::path(path), std::ios::binary | std::ios::ate); + if (!input) { error = "could not open file"; return false; } + const std::streamoff size = input.tellg(); + if (size < 0 || static_cast(size) > kMaxFileBytes) { + error = "file is too large"; return false; + } + std::vector bytes(static_cast(size)); + input.seekg(0, std::ios::beg); + if (!bytes.empty()) input.read(reinterpret_cast(bytes.data()), size); + if (!input && size != 0) { error = "could not read complete file"; return false; } + return deserialize(bytes, document, error); +} + +bool saveFile(const std::string& path, const Document& document, std::string& error) { + std::vector bytes; + if (!serialize(document, bytes, error)) return false; + return commitBytes(path, bytes, error); +} + +namespace { +bool deserializePublicSigned(const std::vector& bytes, Document& document, std::string& error); +} + +bool deserializeSigned(const std::vector& bytes, Document& document, + const std::array& signingKey, + std::string& error) { + if (bytes.size() >= kPublicSignatureBytes && + std::memcmp(bytes.data() + bytes.size() - kPublicSignatureBytes, "SPP3", 4) == 0) { + return deserializePublicSigned(bytes, document, error); + } + if (bytes.size() < kSignatureBytes || std::memcmp(bytes.data() + bytes.size() - kSignatureBytes, "SPPS", 4) != 0) { + // Read-only compatibility for v1 notes; the next save upgrades them. + if (bytes.size() >= 6 && (bytes[4] != 1 || bytes[5] != 0)) { + error = "signed Switchnote is missing its signature"; + return false; + } + return deserialize(bytes, document, error); + } + const std::size_t bodySize = bytes.size() - kSignatureBytes; + const auto expected = hmacSha256(signingKey, bytes.data(), bodySize); + unsigned difference = 0; + for (std::size_t i = 0; i < expected.size(); ++i) difference |= expected[i] ^ bytes[bodySize + 4 + i]; + if (difference != 0) { error = "Switchnote signature is invalid"; return false; } + std::vector body(bytes.begin(), bytes.begin() + static_cast(bodySize)); + if (body.size() < 6 || (body[4] != 2 && body[4] != 3) || body[5] != 0) { + error = "unsupported signed Switchnote version"; + return false; + } + // Earlier signed notes used a v1 payload with the same envelope marker. + // Authenticate first, then prefer the current layout and fall back to v1. + if (body[4] == 3 && deserialize(body, document, error)) return true; + body[4] = kVersion; + if (deserialize(body, document, error)) return true; + body[4] = kLegacyVersion; + return deserialize(body, document, error); +} + +bool loadSignedFile(const std::string& path, Document& document, + const std::array& signingKey, + std::string& error) { + std::ifstream input(std::filesystem::path(path), std::ios::binary | std::ios::ate); + if (!input) { error = "could not open file"; return false; } + const std::streamoff size = input.tellg(); + if (size < 0 || static_cast(size) > kMaxFileBytes + kSignatureBytes) { + error = "file is too large"; return false; + } + std::vector bytes(static_cast(size)); + input.seekg(0, std::ios::beg); + if (!bytes.empty()) input.read(reinterpret_cast(bytes.data()), size); + if (!input && size != 0) { error = "could not read complete file"; return false; } + return deserializeSigned(bytes, document, signingKey, error); +} + +bool saveSignedFile(const std::string& path, const Document& document, + const std::array& signingKey, + std::string& error) { + std::vector bytes; + if (!serialize(document, bytes, error)) return false; + bytes[4] = kVersion; + const auto signature = hmacSha256(signingKey, bytes.data(), bytes.size()); + bytes.insert(bytes.end(), {'S', 'P', 'P', 'S'}); + bytes.insert(bytes.end(), signature.begin(), signature.end()); + return commitBytes(path, bytes, error); +} + +namespace { + +void appendIdentity(std::vector& out, const PublicIdentity& identity) { + out.insert(out.end(), identity.key.begin(), identity.key.end()); + out.insert(out.end(), identity.deviceTag.begin(), identity.deviceTag.end()); +} + +bool readIdentity(const std::vector& bytes, std::size_t& offset, PublicIdentity& identity) { + if (offset > bytes.size() || bytes.size() - offset < identity.key.size() + identity.deviceTag.size()) return false; + std::copy_n(bytes.begin() + static_cast(offset), identity.key.size(), identity.key.begin()); + offset += identity.key.size(); + std::copy_n(bytes.begin() + static_cast(offset), identity.deviceTag.size(), identity.deviceTag.begin()); + offset += identity.deviceTag.size(); + return true; +} + +std::array publicSignatureDigest(const std::vector& body, + const PublicIdentity& author, + const PublicIdentity& original, + const PublicIdentity& editor) { + constexpr char domain[] = "FlipnoteNX-SPPM-public-signature-v3"; + Sha256 hash; + hash.update(reinterpret_cast(domain), sizeof(domain)); + hash.update(body.data(), body.size()); + hash.update(author.key.data(), author.key.size()); + hash.update(author.deviceTag.data(), author.deviceTag.size()); + hash.update(original.key.data(), original.key.size()); + hash.update(original.deviceTag.data(), original.deviceTag.size()); + hash.update(editor.key.data(), editor.key.size()); + hash.update(editor.deviceTag.data(), editor.deviceTag.size()); + return hash.finish(); +} + +bool deserializePublicSigned(const std::vector& bytes, Document& document, std::string& error) { + if (bytes.size() < kPublicSignatureBytes) { error = "public signature is truncated"; return false; } + const std::size_t footer = bytes.size() - kPublicSignatureBytes; + if (std::memcmp(bytes.data() + footer, "SPP3", 4) != 0) { error = "public signature marker is invalid"; return false; } + std::size_t offset = footer + 4; + PublicIdentity author, original, editor; + if (!readIdentity(bytes, offset, author) || !readIdentity(bytes, offset, original) || !readIdentity(bytes, offset, editor) || + bytes.size() - offset != 64 || !hasPublicIdentity(editor)) { + error = "public identity footer is invalid"; return false; + } + std::vector body(bytes.begin(), bytes.begin() + static_cast(footer)); + if (body.size() < 6 || body[4] != 3 || body[5] != 0) { error = "unsupported public signed Switchnote version"; return false; } + const auto digest = publicSignatureDigest(body, author, original, editor); + std::array signedDigest{}; + std::copy_n(bytes.begin() + static_cast(offset), 64, signedDigest.begin()); + std::copy(digest.begin(), digest.end(), signedDigest.begin() + 64); + // crypto_sign_open copies the complete message into its output buffer + // before checking the signature. The signed payload here is 64 bytes of + // Ed25519 signature plus the 32-byte digest, so the destination must be + // the full 96 bytes even though only the digest is compared below. + std::array verifiedMessage{}; + unsigned long long verifiedLength = 0; + if (crypto_sign_ed25519_tweet_open(verifiedMessage.data(), &verifiedLength, signedDigest.data(), signedDigest.size(), editor.key.data()) != 0 || + verifiedLength != digest.size() || !std::equal(digest.begin(), digest.end(), verifiedMessage.begin())) { + error = "Switchnote public signature is invalid"; return false; + } + Document parsed; + body[4] = kVersion; + if (!deserialize(body, parsed, error)) { + body[4] = kLegacyVersion; + if (!deserialize(body, parsed, error)) return false; + } + parsed.metadata.authorIdentity = author; + parsed.metadata.originalAuthorIdentity = original; + parsed.metadata.lastEditorIdentity = editor; + if (!validate(parsed, error)) return false; + document = std::move(parsed); + error.clear(); + return true; +} + +} // namespace + +bool savePublicSignedFile(const std::string& path, const Document& document, + const SigningIdentity& identity, std::string& error) { + if (!hasPublicIdentity(identity.publicIdentity) || + !sameId(deriveFsid(identity.publicIdentity), document.metadata.lastEditorFsid) || + !hasPublicIdentity(document.metadata.lastEditorIdentity) || + document.metadata.lastEditorIdentity.key != identity.publicIdentity.key || + document.metadata.lastEditorIdentity.deviceTag != identity.publicIdentity.deviceTag) { + error = "document last editor does not match signing identity"; + return false; + } + std::vector body; + if (!serialize(document, body, error)) return false; + body[4] = 3; + const auto digest = publicSignatureDigest(body, document.metadata.authorIdentity, + document.metadata.originalAuthorIdentity, + document.metadata.lastEditorIdentity); + std::array signedDigest{}; + unsigned long long signedLength = 0; + if (crypto_sign_ed25519_tweet(signedDigest.data(), &signedLength, digest.data(), digest.size(), identity.secretKey.data()) != 0 || + signedLength != signedDigest.size()) { + error = "could not sign Switchnote"; + return false; + } + body.insert(body.end(), {'S', 'P', 'P', '3'}); + appendIdentity(body, document.metadata.authorIdentity); + appendIdentity(body, document.metadata.originalAuthorIdentity); + appendIdentity(body, document.metadata.lastEditorIdentity); + body.insert(body.end(), signedDigest.begin(), signedDigest.begin() + 64); + return commitBytes(path, body, error); +} + +bool signMessage(const SigningIdentity& identity, const std::uint8_t* message, + std::size_t messageSize, std::array& signature, + std::string& error) { + if (!hasPublicIdentity(identity.publicIdentity) || (messageSize && !message) || + !std::equal(identity.publicIdentity.key.begin(), identity.publicIdentity.key.end(), + identity.secretKey.begin() + 32) || + messageSize > std::numeric_limits::max() - 64 || + messageSize > std::numeric_limits::max() - 64) { + error = "invalid signing identity or request message"; + return false; + } + std::vector signedMessage(messageSize + 64); + unsigned long long signedLength = 0; + if (crypto_sign_ed25519_tweet(signedMessage.data(), &signedLength, message, + static_cast(messageSize), + identity.secretKey.data()) != 0 || + signedLength != signedMessage.size()) { + std::fill(signedMessage.begin(), signedMessage.end(), 0); + error = "could not sign Gallery request"; + return false; + } + std::copy_n(signedMessage.begin(), signature.size(), signature.begin()); + std::fill(signedMessage.begin(), signedMessage.end(), 0); + error.clear(); + return true; +} + +bool createSpinoff(const Document& parent, std::string_view newAuthorName, + std::string_view newAuthorFsid, std::string_view newCurrentId, + std::uint64_t nowUnixSeconds, Document& child, + std::string& error) { + if (!validate(parent, error)) return false; + if (parent.metadata.locked || !parent.metadata.allowSpinoffs) { + error = "this Switchnote does not permit spin-offs"; return false; + } + if (newAuthorName.size() > 128 || newAuthorName.find('\0') != std::string_view::npos || + !validUtf8(newAuthorName) || + !isValidFsid(newAuthorFsid) || !isValidFsid(newCurrentId) || + sameId(newCurrentId, parent.metadata.currentId) || + sameId(newCurrentId, parent.metadata.rootId) || + nowUnixSeconds < parent.metadata.createdUnixSeconds) { + error = "invalid spin-off identity or creation time"; return false; + } + Document fork = parent; + Metadata& m = fork.metadata; + m.authorName.assign(newAuthorName); + m.authorFsid = upperId(newAuthorFsid); + m.authorIdentity = {}; + m.lastEditorName = m.authorName; + m.lastEditorFsid = m.authorFsid; + m.lastEditorIdentity = {}; + m.parentId = upperId(parent.metadata.currentId); + m.currentId = upperId(newCurrentId); + m.createdUnixSeconds = nowUnixSeconds; + m.modifiedUnixSeconds = nowUnixSeconds; + m.locked = false; + if (!validate(fork, error)) return false; + child = std::move(fork); + error.clear(); + return true; +} + +} // namespace sppm diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..08fb1eb --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.16) +project(sppm_test LANGUAGES C CXX) + +add_executable(sppm_test ../source/sppm.cpp ../third_party/tweetnacl.c sppm_test.cpp) +target_include_directories(sppm_test PRIVATE ../include ../third_party) +target_compile_features(sppm_test PRIVATE cxx_std_17) + +enable_testing() +add_test(NAME sppm_roundtrip COMMAND sppm_test) diff --git a/tests/audio_upload_page_test.js b/tests/audio_upload_page_test.js new file mode 100644 index 0000000..af5615b --- /dev/null +++ b/tests/audio_upload_page_test.js @@ -0,0 +1,70 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +async function main() { + const source = fs.readFileSync(path.join(__dirname, '..', 'source', 'audio_upload_server.cpp'), 'utf8'); + const script = source.match(/