Add files via upload
This commit is contained in:
35 files changed
+14482
No files matched your search
@@ -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.
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
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.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -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
|
||||||
@@ -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/`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
B0B56FDC81766C8740622AC81BA5E72E90293099CBDB96B5BBF0372AC0C10CCA switchnote.nro
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace branding {
|
||||||
|
|
||||||
|
struct Bundle {
|
||||||
|
std::string app;
|
||||||
|
std::string singular;
|
||||||
|
std::string plural;
|
||||||
|
std::vector<unsigned char> 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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "sppm.hpp"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace sppm {
|
||||||
|
|
||||||
|
constexpr std::uint16_t kWidth = 640;
|
||||||
|
constexpr std::uint16_t kHeight = 360;
|
||||||
|
constexpr std::size_t kPixelsPerLayer = static_cast<std::size_t>(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<Color, kPaletteSize>;
|
||||||
|
|
||||||
|
// 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<std::vector<std::uint8_t>, 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<std::uint16_t> frameTriggers; // SE cues; BGM always starts at frame 0
|
||||||
|
std::vector<std::int16_t> 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<std::uint8_t, 32> key{};
|
||||||
|
std::array<std::uint8_t, 16> deviceTag{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SigningIdentity {
|
||||||
|
PublicIdentity publicIdentity;
|
||||||
|
std::array<std::uint8_t, 64> secretKey{};
|
||||||
|
// Present only to keep validating pre-v3 console-HMAC files after migration.
|
||||||
|
std::array<std::uint8_t, 32> 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<Frame> frames;
|
||||||
|
std::vector<AudioTrack> 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<std::uint8_t, 16> deriveDeviceTag(const std::vector<std::uint8_t>& deviceMaterial);
|
||||||
|
std::array<std::uint8_t, 32> deriveDeviceWrapKey(const std::vector<std::uint8_t>& deviceMaterial,
|
||||||
|
const std::array<std::uint8_t, 16>& salt);
|
||||||
|
bool createSigningIdentity(const std::array<std::uint8_t, 16>& deviceTag,
|
||||||
|
SigningIdentity& identity, std::string& error);
|
||||||
|
bool sealSigningSecret(const SigningIdentity& identity,
|
||||||
|
const std::array<std::uint8_t, 32>& wrapKey,
|
||||||
|
const std::array<std::uint8_t, 24>& nonce,
|
||||||
|
std::array<std::uint8_t, 128>& sealed, std::string& error);
|
||||||
|
bool unsealSigningSecret(const std::array<std::uint8_t, 128>& sealed,
|
||||||
|
const std::array<std::uint8_t, 32>& wrapKey,
|
||||||
|
const std::array<std::uint8_t, 24>& 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<std::uint8_t, 32>& secret,
|
||||||
|
const std::array<std::uint8_t, 16>& 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<std::uint8_t>& bytes,
|
||||||
|
std::string& error);
|
||||||
|
bool deserialize(const std::vector<std::uint8_t>& 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<std::uint8_t>& bytes, Document& document,
|
||||||
|
const std::array<std::uint8_t, 32>& signingKey,
|
||||||
|
std::string& error);
|
||||||
|
bool loadSignedFile(const std::string& path, Document& document,
|
||||||
|
const std::array<std::uint8_t, 32>& signingKey,
|
||||||
|
std::string& error);
|
||||||
|
bool saveSignedFile(const std::string& path, const Document& document,
|
||||||
|
const std::array<std::uint8_t, 32>& 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<std::uint8_t, 64>& 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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,701 @@
|
|||||||
|
#include "audio_upload_server.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fstream>
|
||||||
|
#include <map>
|
||||||
|
#include <new>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#ifdef __SWITCH__
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <sys/select.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <sys/time.h>
|
||||||
|
#include <switch.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#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<bool> running{false};
|
||||||
|
std::atomic<bool> connected{false};
|
||||||
|
std::atomic<int> pendingTrack{-1};
|
||||||
|
std::atomic<int> uploadOutcome{0}; // 0 idle, 1 processing, 2 imported, 3 rejected
|
||||||
|
std::atomic<int> 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<std::uint8_t, 32>& 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<char>(std::tolower(static_cast<unsigned char>(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<unsigned int>(a.size() ^ b.size());
|
||||||
|
for (std::size_t i = 0; i < length; ++i) {
|
||||||
|
const unsigned char av = i < a.size() ? static_cast<unsigned char>(a[i]) : 0;
|
||||||
|
const unsigned char bv = i < b.size() ? static_cast<unsigned char>(b[i]) : 0;
|
||||||
|
difference |= static_cast<unsigned int>(av ^ bv);
|
||||||
|
}
|
||||||
|
return difference == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool sendAll(int fd, const void* bytes, std::size_t length) {
|
||||||
|
const auto* data = static_cast<const std::uint8_t*>(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<std::size_t>(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("' </style></head>", "</style></head>");
|
||||||
|
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(<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Audio upload</title>
|
||||||
|
<style>
|
||||||
|
@font-face{font-family:Seurat;src:url('/assets/font') format('opentype')}*{box-sizing:border-box}body{margin:0;min-height:100vh;color:#252831;font-family:Seurat,'Trebuchet MS',sans-serif;background-color:#fff;background-image:linear-gradient(#d9e2ef 1px,transparent 1px),linear-gradient(90deg,#d9e2ef 1px,transparent 1px);background-size:24px 24px}.shell{max-width:920px;margin:5vh auto;padding:22px}.card{background:#fff;border:2px solid #e2e6f2;border-radius:18px;box-shadow:0 5px 0 #d5d8df,0 8px 20px #18243b22;padding:clamp(20px,4vw,34px)}p{line-height:1.5;color:#6c7280;margin-top:0}label{display:block;font-weight:700;margin:18px 0 7px}input,select{width:100%;font:inherit;padding:13px 14px;border:2px solid #d8dce5;border-radius:12px;background:#fff;color:#252831}input:focus,select:focus{outline:3px solid #ffb77e;border-color:#ff751e}.row{display:grid;grid-template-columns:1fr 1fr;gap:16px}.button{margin-top:22px;width:100%;padding:14px 18px;border:3px solid #fff;border-radius:28px;background:#ffd3a6;color:#ee6818;font:inherit;font-weight:700;box-shadow:0 4px 0 #cfd2d8,0 0 0 2px #e5e7ec;cursor:pointer;transition:transform .12s,box-shadow .12s}.button:hover{transform:translateY(-2px)}.button:active{transform:translateY(2px);box-shadow:0 1px 0 #cfd2d8}.button:disabled{opacity:.5;cursor:not-allowed}.status{min-height:28px;margin:15px 0 0;padding:10px 12px;border-radius:10px;background:#f1f3f8;color:#555d6b}.status.ok{background:#effbdd;color:#4e8d2c}.status.error{background:#fff0ed;color:#ba342d}.connected{margin:0 0 20px;padding:15px 18px;border-radius:13px;background:#effbdd;color:#4e8d2c;font-size:20px}.footer{margin:20px 4px;color:#697183;font-size:13px}[hidden]{display:none!important}@media(max-width:620px){.shell{padding:14px}.row{grid-template-columns:1fr}.card{padding:20px}}
|
||||||
|
</style></head><body><main class="shell">
|
||||||
|
<section class="card"><div id="pair"><p>Enter the code shown in Sound on your Nintendo Switch.</p>
|
||||||
|
<label for="code">Connection code</label><input id="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" maxlength="7" placeholder="000-000"><div id="pairStatus" class="status" role="status">Enter all six digits to connect.</div></div>
|
||||||
|
<div id="connected" class="connected" role="status" hidden>Connected to Nintendo Switch</div>
|
||||||
|
<div id="uploadPanel" hidden><p>Choose a track and an audio file. BGM is trimmed to 60 seconds; each sound effect is trimmed to 2 seconds.</p>
|
||||||
|
<div class="row"><div><label for="track">Track</label><select id="track"><option value="0">BGM · Music</option><option value="1">SE1 · Sound effect 1</option><option value="2">SE2 · Sound effect 2</option><option value="3">SE3 · Sound effect 3</option></select></div><div><label for="file">Audio file</label><input id="file" type="file" accept="audio/*,.wav,.mp3,.ogg,.m4a,.flac"></div></div>
|
||||||
|
<button class="button" id="send">Upload to Switch</button><div id="status" class="status" role="status">Ready to upload.</div></div></section>
|
||||||
|
<div class="footer">Ver 0.1.51</div></main>
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
let sessionToken = '', connecting = false;
|
||||||
|
function message(value, kind = '') {
|
||||||
|
const target = sessionToken ? $('status') : $('pairStatus');
|
||||||
|
target.className = 'status' + (kind ? ' ' + kind : ''); target.textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(code) {
|
||||||
|
if (connecting || sessionToken) return;
|
||||||
|
connecting = true; message('Connecting…');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/connect', {method: 'POST', headers: {'Authorization': 'Bearer ' + code}});
|
||||||
|
const reply = (await response.text()).trim();
|
||||||
|
if (!response.ok) throw new Error(reply || 'Could not connect to the Switch.');
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(reply)) throw new Error('The Switch sent an invalid connection response.');
|
||||||
|
if ($('code').value !== code) return;
|
||||||
|
sessionToken = reply;
|
||||||
|
$('pair').hidden = true; $('connected').hidden = false; $('uploadPanel').hidden = false;
|
||||||
|
} catch (error) { message(error.message || 'Could not connect to the Switch.', 'error'); }
|
||||||
|
finally { connecting = false; }
|
||||||
|
}
|
||||||
|
$('code').addEventListener('input', () => {
|
||||||
|
const digits = $('code').value.replace(/\D/g, '').slice(0, 6);
|
||||||
|
$('code').value = digits.length > 3 ? digits.slice(0, 3) + '-' + digits.slice(3) : digits;
|
||||||
|
if (digits.length === 6) connect($('code').value);
|
||||||
|
});
|
||||||
|
async function wavFromFile(file, maxSeconds) {
|
||||||
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AudioContextClass) throw new Error('This browser cannot convert audio. Try a WAV file in a newer browser.');
|
||||||
|
const context = new AudioContextClass({sampleRate: 48000});
|
||||||
|
try {
|
||||||
|
const audio = await context.decodeAudioData(await file.arrayBuffer());
|
||||||
|
if (!audio.length) throw new Error('This audio file is empty.');
|
||||||
|
const frames = Math.min(audio.length, Math.floor(maxSeconds * audio.sampleRate));
|
||||||
|
const data = new ArrayBuffer(44 + frames * 4), view = new DataView(data);
|
||||||
|
const write = (at, text) => { for (let i = 0; i < text.length; i++) view.setUint8(at + i, text.charCodeAt(i)); };
|
||||||
|
write(0, 'RIFF'); view.setUint32(4, data.byteLength - 8, true); write(8, 'WAVE');
|
||||||
|
write(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
|
||||||
|
view.setUint16(22, 2, true); view.setUint32(24, audio.sampleRate, true);
|
||||||
|
view.setUint32(28, audio.sampleRate * 4, true); view.setUint16(32, 4, true);
|
||||||
|
view.setUint16(34, 16, true); write(36, 'data'); view.setUint32(40, frames * 4, true);
|
||||||
|
const left = audio.getChannelData(0), right = audio.getChannelData(Math.min(1, audio.numberOfChannels - 1));
|
||||||
|
for (let i = 0; i < frames; i++) {
|
||||||
|
view.setInt16(44 + i * 4, Math.round(Math.max(-1, Math.min(1, left[i])) * 32767), true);
|
||||||
|
view.setInt16(46 + i * 4, Math.round(Math.max(-1, Math.min(1, right[i])) * 32767), true);
|
||||||
|
}
|
||||||
|
return {blob: new Blob([data], {type: 'audio/wav'}), trimmed: frames < audio.length};
|
||||||
|
} finally { await context.close(); }
|
||||||
|
}
|
||||||
|
$('send').addEventListener('click', async () => {
|
||||||
|
const file = $('file').files[0], track = $('track').value;
|
||||||
|
if (!sessionToken) { message('Connect to the Switch first.', 'error'); return; }
|
||||||
|
if (!file) { message('Choose an audio file first.', 'error'); return; }
|
||||||
|
if (file.size > 128 * 1024 * 1024) { message('This source file is too large to prepare in the browser.', 'error'); return; }
|
||||||
|
$('send').disabled = true;
|
||||||
|
try {
|
||||||
|
message('Preparing ' + file.name + '…');
|
||||||
|
const prepared = await wavFromFile(file, track === '0' ? 60 : 2), wav = prepared.blob;
|
||||||
|
if (wav.size > (track === '0' ? 32 : 3) * 1024 * 1024)
|
||||||
|
throw new Error('The converted audio is too large for this track.');
|
||||||
|
message('Sending ' + file.name + '…');
|
||||||
|
const headers = {'Authorization': 'Bearer ' + sessionToken};
|
||||||
|
const response = await fetch('/api/upload', {method: 'POST', headers: {...headers, 'Content-Type': 'audio/wav', 'X-Audio-Track': track}, body: wav});
|
||||||
|
const reply = await response.text();
|
||||||
|
if (!response.ok) throw new Error(reply || 'Upload failed.');
|
||||||
|
message('Adding audio to the {{NOTE}}…');
|
||||||
|
let outcome = 'processing';
|
||||||
|
for (let i = 0; i < 60 && outcome === 'processing'; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 250));
|
||||||
|
const result = await fetch('/api/status', {headers});
|
||||||
|
if (!result.ok) throw new Error('The Sound menu was closed before import finished.');
|
||||||
|
outcome = (await result.text()).trim();
|
||||||
|
}
|
||||||
|
if (outcome !== 'imported') throw new Error(outcome === 'processing' ? 'The Switch is still processing this audio. Check the Sound menu.' : 'The Switch could not add this audio. Check its duration.');
|
||||||
|
message(prepared.trimmed ? 'Audio added and trimmed to ' + (track === '0' ? '60' : '2') + ' seconds.' : 'Audio added to the {{NOTE}}.', 'ok'); $('file').value = '';
|
||||||
|
} catch (error) { message(error.message || 'Upload failed. Keep this page open and try again.', 'error'); }
|
||||||
|
finally { $('send').disabled = false; }
|
||||||
|
});
|
||||||
|
</script></body></html>)HTML", noteSingular);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string photoPage(const std::string& noteSingular) {
|
||||||
|
return brandedPage(R"HTML(<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Photo import</title><style>
|
||||||
|
@font-face{font-family:Seurat;src:url('/assets/font') format('opentype')}*{box-sizing:border-box}body{margin:0;min-height:100vh;color:#252831;font-family:Seurat,'Trebuchet MS',sans-serif;background:#fff;background-image:linear-gradient(#d9e2ef 1px,transparent 1px),linear-gradient(90deg,#d9e2ef 1px,transparent 1px);background-size:24px 24px}.shell{max-width:1000px;margin:3vh auto;padding:18px}.card{background:#fff;border:2px solid #e2e6f2;border-radius:18px;box-shadow:0 5px 0 #d5d8df,0 8px 20px #18243b22;padding:clamp(18px,3vw,30px)}h1{margin:0 0 8px;font-size:30px}p{line-height:1.45;color:#6c7280;margin-top:0}.connected{padding:12px 15px;border-radius:13px;background:#effbdd;color:#4e8d2c;font-size:18px}label{display:block;margin:13px 0 6px}input,select{width:100%;padding:10px 12px;border:2px solid #d8dce5;border-radius:10px;background:white;color:#252831;font:inherit}.grid{display:grid;grid-template-columns:minmax(220px,1fr) minmax(300px,1.25fr);gap:20px}.opts{display:grid;grid-template-columns:1fr 1fr;gap:10px}.preview{width:100%;aspect-ratio:5/3;background-color:#e8ecf4;background-image:linear-gradient(45deg,#cad0dc 25%,transparent 25%),linear-gradient(-45deg,#cad0dc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#cad0dc 75%),linear-gradient(-45deg,transparent 75%,#cad0dc 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0;border:2px solid #d8dce5;border-radius:12px;image-rendering:pixelated}.button{margin-top:16px;width:100%;padding:13px;border:3px solid white;border-radius:28px;background:#ffd3a6;color:#ee6818;font:inherit;box-shadow:0 4px 0 #cfd2d8,0 0 0 2px #e5e7ec;cursor:pointer}.button:disabled{opacity:.5}.status{margin-top:12px;padding:10px;border-radius:9px;background:#f1f3f8;color:#555d6b}.status.error{background:#fff0ed;color:#ba342d}[hidden]{display:none!important}@media(max-width:680px){.grid{grid-template-columns:1fr}.opts{grid-template-columns:1fr 1fr}}
|
||||||
|
' </style></head><body><main class="shell"><section class="card"><h1>Photo Import</h1><div id="pair"><p>Connect to your {{NOTE}} over local Wi-Fi using the code shown on screen.</p><label>Connection code</label><input id="code" inputmode="numeric" maxlength="7" placeholder="000-000"><div id="pairStatus" class="status">Enter all six digits to connect.</div></div><div id="panel" hidden><div class="connected">Connected to Nintendo Switch</div><p>Choose an image and adjust its canvas placement before sending it to the current {{NOTE}}.</p><div class="grid"><div><label>Image</label><input id="file" type="file" accept="image/png,image/jpeg,image/webp"><div class="opts"><div><label>Color mode</label><select id="mode"><option value="color">Full color</option><option value="ink">Single ink</option></select></div><div><label>Dithering</label><select id="dither"><option value="none">None</option><option value="ordered">Ordered</option></select></div><div><label>Ink color</label><input id="ink" type="color" value="#171717"></div><div><label>Layer</label><select id="layer"><option value="0">A · Back</option><option value="1">B</option><option value="2">C</option><option value="3">D · Front</option></select></div><div><label>Size</label><input id="size" type="range" min="25" max="200" value="100"><small id="sizeLabel">100%</small></div><div><label>Position X</label><input id="px" type="range" min="-300" max="300" value="0"></div><div><label>Position Y</label><input id="py" type="range" min="-180" max="180" value="0"></div></div></div><div><canvas id="preview" class="preview" width="600" height="360"></canvas><button class="button" id="send" disabled>Upload to {{NOTE}}</button><div id="status" class="status">Choose an image to preview.</div></div></div></section></main><script>
|
||||||
|
'use strict';const $=id=>document.getElementById(id);let token='',busy=false,img=null;const canvas=$('preview'),ctx=canvas.getContext('2d',{willReadFrequently:true});function say(v,e=false){$('status').textContent=v;$('status').className='status'+(e?' error':'')}async function pair(code){if(busy||token)return;busy=true;$('pairStatus').textContent='Connecting…';try{const r=await fetch('/api/connect',{method:'POST',headers:{Authorization:'Bearer '+code}}),t=(await r.text()).trim();if(!r.ok)throw Error(t||'Could not connect.');if(!/^[a-f0-9]{64}$/.test(t))throw Error('Invalid Switch response.');token=t;$('pair').hidden=true;$('panel').hidden=false}catch(e){$('pairStatus').textContent=e.message;$('pairStatus').className='status error'}finally{busy=false}}$('code').addEventListener('input',()=>{const d=$('code').value.replace(/\D/g,'').slice(0,6);$('code').value=d.length>3?d.slice(0,3)+'-'+d.slice(3):d;if(d.length===6)pair($('code').value)});function render(){if(!img)return;ctx.clearRect(0,0,600,360);const s=Number($('size').value)/100,w=img.width*s,h=img.height*s,x=(600-w)/2+Number($('px').value),y=(360-h)/2+Number($('py').value);ctx.drawImage(img,x,y,w,h);const im=ctx.getImageData(0,0,600,360),p=im.data,ink=$('ink').value.match(/[a-f\d]{2}/gi).map(v=>parseInt(v,16)),mono=$('mode').value==='ink',ordered=$('dither').value==='ordered',bayer=[0,8,2,10,12,4,14,6,3,11,1,9,15,7,13,5];for(let yy=0;yy<360;yy++)for(let xx=0;xx<600;xx++){const i=(yy*600+xx)*4;if(p[i+3]<24){p[i+3]=0;continue}if(mono){let l=(p[i]*299+p[i+1]*587+p[i+2]*114)/1000;if(ordered)l+=(bayer[(yy&3)*4+(xx&3)]-7.5)*12;const v=l<128?0:255;p[i]=v?255:ink[0];p[i+1]=v?255:ink[1];p[i+2]=v?255:ink[2]}else if(ordered){const t=(bayer[(yy&3)*4+(xx&3)]-7.5)*3;p[i]=Math.max(0,Math.min(255,p[i]+t));p[i+1]=Math.max(0,Math.min(255,p[i+1]+t));p[i+2]=Math.max(0,Math.min(255,p[i+2]+t))}}ctx.putImageData(im,0,0);$('sizeLabel').textContent=$('size').value+'%';$('send').disabled=!token}$('file').addEventListener('change',()=>{const f=$('file').files[0];if(!f)return;if(f.size>16*1024*1024){say('Choose an image under 16 MB.',true);return}img=new Image();img.onload=()=>{render();say('Preview ready.');};img.onerror=()=>say('Could not read that image.',true);img.src=URL.createObjectURL(f)});['mode','dither','ink','size','px','py'].forEach(id=>$(id).addEventListener('input',render));$('send').addEventListener('click',async()=>{if(!token||!img||busy)return;busy=true;$('send').disabled=true;try{render();say('Preparing image…');const data=ctx.getImageData(0,0,600,360).data;const r=await fetch('/api/photo',{method:'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/octet-stream','X-Photo-Layer':$('layer').value,'X-Photo-Mode':$('mode').value,'X-Photo-Dither':$('dither').value,'X-Photo-Size':$('size').value,'X-Photo-X':$('px').value,'X-Photo-Y':$('py').value},body:data});const t=await r.text();if(!r.ok)throw Error(t||'Upload failed.');say('Image sent. Importing to the selected layer…');let o='processing';for(let i=0;i<80&&o==='processing';i++){await new Promise(a=>setTimeout(a,250));const q=await fetch('/api/status',{headers:{Authorization:'Bearer '+token}});if(!q.ok)throw Error('Photo menu closed before import finished.');o=(await q.text()).trim()}if(o!=='imported')throw Error(o==='rejected'?'Could not import this image.':'Switch is still processing the image.');say('Photo added to the Switchnote.');}catch(e){say(e.message||'Upload failed.',true)}finally{busy=false;$('send').disabled=false}});
|
||||||
|
</script></body></html>)HTML", noteSingular);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool readAsset(const std::string& path, std::vector<char>& 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<std::size_t>(length));
|
||||||
|
file.seekg(0, std::ios::beg);
|
||||||
|
return static_cast<bool>(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<std::size_t>(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<std::streamsize>(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<std::size_t>(count));
|
||||||
|
received += static_cast<std::size_t>(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<std::string, std::string>& 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<int>(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<std::streamsize>(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<std::size_t>(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<std::string, std::string> 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<char> 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<std::size_t>(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<std::size_t>(bodyLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
void serverThread(void* argument) {
|
||||||
|
auto* state = static_cast<ServerState*>(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<sockaddr*>(&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<std::uint8_t, 32> 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<sockaddr*>(&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<ServerState*>(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<const ServerState*>(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<const ServerState*>(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<ServerState*>(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<ServerState*>(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
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
#include "branding_bundle.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iterator>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace branding {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void put16(std::vector<unsigned char>& out, std::uint16_t v) {
|
||||||
|
out.push_back(static_cast<unsigned char>(v));
|
||||||
|
out.push_back(static_cast<unsigned char>(v >> 8));
|
||||||
|
}
|
||||||
|
void put32(std::vector<unsigned char>& out, std::uint32_t v) {
|
||||||
|
put16(out, static_cast<std::uint16_t>(v));
|
||||||
|
put16(out, static_cast<std::uint16_t>(v >> 16));
|
||||||
|
}
|
||||||
|
std::uint16_t get16(const std::vector<unsigned char>& in, std::size_t p) {
|
||||||
|
return static_cast<std::uint16_t>(in[p] | (static_cast<std::uint16_t>(in[p + 1]) << 8));
|
||||||
|
}
|
||||||
|
std::uint32_t get32(const std::vector<unsigned char>& in, std::size_t p) {
|
||||||
|
return static_cast<std::uint32_t>(get16(in, p)) |
|
||||||
|
(static_cast<std::uint32_t>(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<char>(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<char>(c));
|
||||||
|
}
|
||||||
|
out.push_back('"');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
void appendUtf8(std::string& out, std::uint32_t c) {
|
||||||
|
if (c <= 0x7f) out.push_back(static_cast<char>(c));
|
||||||
|
else if (c <= 0x7ff) { out.push_back(static_cast<char>(0xc0 | (c >> 6))); out.push_back(static_cast<char>(0x80 | (c & 63))); }
|
||||||
|
else { out.push_back(static_cast<char>(0xe0 | (c >> 12))); out.push_back(static_cast<char>(0x80 | ((c >> 6) & 63))); out.push_back(static_cast<char>(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<unsigned char>(json[p++]);
|
||||||
|
if (c == '"') return true;
|
||||||
|
if (c != '\\') { value.push_back(static_cast<char>(c)); continue; }
|
||||||
|
if (p >= json.size()) return false;
|
||||||
|
c = static_cast<unsigned char>(json[p++]);
|
||||||
|
switch (c) {
|
||||||
|
case '"': case '\\': case '/': value.push_back(static_cast<char>(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<unsigned>(h - '0');
|
||||||
|
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
|
||||||
|
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(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<const unsigned char*>(jsonOwned.data()), bundle.logo.data()};
|
||||||
|
const std::uint32_t sizes[2] = {static_cast<std::uint32_t>(jsonOwned.size()), static_cast<std::uint32_t>(bundle.logo.size())};
|
||||||
|
std::vector<unsigned char> 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<std::uint32_t>(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<std::uint16_t>(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<std::uint32_t>(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<std::uint16_t>(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<std::uint32_t>(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<const char*>(archive.data()), static_cast<std::streamsize>(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<unsigned char> bytes(static_cast<std::size_t>(length)); in.seekg(0); in.read(reinterpret_cast<char*>(bytes.data()), length);
|
||||||
|
if (!in) { error = "Could not read branding config."; return false; }
|
||||||
|
std::string json; std::vector<unsigned char> 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<std::ptrdiff_t>(nameAt), bytes.begin() + static_cast<std::ptrdiff_t>(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<const char*>(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
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
#include "branding_config_server.hpp"
|
||||||
|
#include "branding_bundle.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <ctime>
|
||||||
|
#include <fstream>
|
||||||
|
#include <map>
|
||||||
|
#include <new>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#ifdef __SWITCH__
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <sys/select.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <sys/time.h>
|
||||||
|
#include <switch.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#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<bool> running{false};
|
||||||
|
std::atomic<bool> connected{false};
|
||||||
|
std::atomic<bool> pending{false};
|
||||||
|
std::atomic<std::uint64_t> lastSeenSeconds{0};
|
||||||
|
std::atomic<int> 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<long long>(std::time(nullptr)) << " " << message << '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool equalToken(const std::string& a, const std::string& b) {
|
||||||
|
unsigned int diff = static_cast<unsigned int>(a.size() ^ b.size());
|
||||||
|
for (std::size_t i = 0; i < std::max(a.size(), b.size()); ++i)
|
||||||
|
diff |= static_cast<unsigned int>((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<std::size_t>(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(<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Branding Config Portal</title><style>
|
||||||
|
@font-face{font-family:Seurat;src:url('/assets/font')}*{box-sizing:border-box}body{margin:0;min-height:100vh;color:#252831;font-family:Seurat,'Trebuchet MS',sans-serif;background:#fff;background-image:linear-gradient(#d9e2ef 1px,transparent 1px),linear-gradient(90deg,#d9e2ef 1px,transparent 1px);background-size:24px 24px}.shell{max-width:920px;margin:5vh auto;padding:22px}.card{background:#fff;border:2px solid #e2e6f2;border-radius:18px;box-shadow:0 5px 0 #d5d8df,0 8px 20px #18243b22;padding:clamp(20px,4vw,34px)}h1{font-size:clamp(24px,4vw,34px);margin:0 0 10px}p{line-height:1.5;color:#6c7280;margin-top:0}label{display:block;font-weight:600;margin:18px 0 7px}input{width:100%;font:inherit;padding:13px 14px;border:2px solid #d8dce5;border-radius:12px;background:#fff;color:#252831}input:focus{outline:3px solid #ffb77e;border-color:#ff751e}.button{margin-top:22px;padding:13px 22px;border:3px solid #fff;border-radius:28px;background:#ffd3a6;color:#ee6818;font:inherit;box-shadow:0 4px 0 #cfd2d8,0 0 0 2px #e5e7ec;cursor:pointer;transition:transform .12s}.button:hover{transform:translateY(-2px)}.button:active{transform:translateY(2px)}.button:disabled{opacity:.5}.status{min-height:28px;margin:15px 0 0;padding:10px 12px;border-radius:10px;background:#f1f3f8;color:#555d6b}.status.ok{background:#effbdd;color:#4e8d2c}.status.error{background:#fff0ed;color:#ba342d}.connected{padding:13px 16px;border-radius:13px;background:#effbdd;color:#4e8d2c;font-size:18px}[hidden]{display:none!important}.footer{margin:18px 4px;color:#697183;font-size:13px}@media(max-width:620px){.shell{padding:14px}.card{padding:20px}}
|
||||||
|
</style></head><body><main class="shell"><section class="card"><h1>Branding Config Portal</h1><div id="pair"><p>Enter the code shown on your Nintendo Switch.</p><label for="code">Connection code</label><input id="code" inputmode="numeric" maxlength="7" placeholder="000-000"><div id="pairStatus" class="status">Enter all six digits to connect.</div></div><div id="connected" class="connected" hidden>Connected to Nintendo Switch</div><form id="form" hidden><p>Set the app name and the words used for notes. Upload a transparent PNG or JPEG for the title screen logo. Restart the app after saving for changes to apply.</p><label for="app">App name</label><input id="app" maxlength="40" required value="{{APP_NAME}}"><label for="singular">Singular name</label><input id="singular" maxlength="24" required value="{{NOTE_NAME}}"><label for="plural">Plural name</label><input id="plural" maxlength="24" required value="{{NOTES_NAME}}"><label for="logo">Title screen logo (PNG or JPEG, up to 8 MB)</label><input id="logo" type="file" accept="image/png,image/jpeg"><button class="button" id="save">Save branding</button><div id="status" class="status">Ready.</div></form></section><div class="footer">Ver 0.1.51</div></main><script>
|
||||||
|
'use strict';
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
let token='',busy=false;
|
||||||
|
function msg(v,k=''){const e=token?$('status'):$('pairStatus');e.className='status'+(k?' '+k:'');e.textContent=v}
|
||||||
|
async function pollConnection(){if(!token)return;try{const r=await fetch('/api/status',{headers:{Authorization:'Bearer '+token},cache:'no-store'});if(!r.ok)throw Error();const state=await r.json();$('connected').textContent=state.connected?'Connected to Nintendo Switch':'Disconnected from Nintendo Switch';$('connected').className=state.connected?'connected':'connected status error'}catch{$('connected').textContent='Disconnected from Nintendo Switch';$('connected').className='connected status error'}}
|
||||||
|
async function connect(code){if(busy||token)return;busy=true;msg('Connecting…');try{const r=await fetch('/api/connect',{method:'POST',headers:{Authorization:'Bearer '+code}}),v=(await r.text()).trim();if(!r.ok)throw Error(v||'Could not connect.');if(!/^[a-f0-9]{64}$/.test(v))throw Error('Invalid Switch response.');token=v;$('pair').hidden=true;$('connected').hidden=false;$('form').hidden=false;const current=await fetch('/api/config',{headers:{Authorization:'Bearer '+token}});if(current.ok){const saved=await current.json();$('app').value=saved.app;$('singular').value=saved.singular;$('plural').value=saved.plural}pollConnection()}catch(e){msg(e.message,'error')}finally{busy=false}}
|
||||||
|
$('code').addEventListener('input',()=>{const d=$('code').value.replace(/\D/g,'').slice(0,6);$('code').value=d.length>3?d.slice(0,3)+'-'+d.slice(3):d;if(d.length===6)connect($('code').value)});
|
||||||
|
$('form').addEventListener('submit',async e=>{e.preventDefault();if(!token||busy)return;busy=true;$('save').disabled=true;try{const f=$('logo').files[0];if(f){if(f.size>8*1024*1024)throw Error('Logo must be 8 MB or smaller.');if(!['image/png','image/jpeg'].includes(f.type))throw Error('Choose a PNG or JPEG image.');const upload=await fetch('/api/logo',{method:'PUT',headers:{Authorization:'Bearer '+token,'Content-Type':f.type},body:f});if(!upload.ok)throw Error(await upload.text()||'Logo upload failed.')}const data=new URLSearchParams({app:$('app').value,singular:$('singular').value,plural:$('plural').value});const save=await fetch('/api/config',{method:'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/x-www-form-urlencoded'},body:data});if(!save.ok)throw Error(await save.text()||'Could not save branding.');msg('Branding saved on Nintendo Switch.','ok')}catch(err){msg(err.message||'Save failed.','error')}finally{busy=false;$('save').disabled=false}});
|
||||||
|
setInterval(pollConnection,2000);
|
||||||
|
</script></body></html>)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 = "<label for=\"loading\">Loading animation (GIF or WebP)</label><input id=\"loading\" type=\"file\" accept=\"image/gif,image/webp\"><small>Animated images are reduced to eight small frames for the Switch.</small>";
|
||||||
|
const std::size_t formEnd = html.find("</form>");
|
||||||
|
if (formEnd != std::string::npos) html.insert(formEnd, loaderInput);
|
||||||
|
const std::string bundleControls = "<div style=\"margin-top:24px;padding-top:18px;border-top:2px solid #e2e6f2\"><strong>Branding file</strong><p>Save or load your branding and title logo as a .config file.</p><button class=\"button\" type=\"button\" id=\"exportBundle\">Export .config</button> <button class=\"button\" type=\"button\" id=\"chooseBundle\">Import .config</button><input id=\"bundleFile\" type=\"file\" accept=\".config,application/zip\" hidden></div>";
|
||||||
|
const std::size_t bundleFormEnd = html.find("</form>");
|
||||||
|
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<std::size_t>(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<headers.size()&&headers[pos]==' ')++pos;bodySize=static_cast<std::size_t>(std::strtoul(headers.c_str()+pos,nullptr,10));}
|
||||||
|
if(bodySize>kMaxRequest)return false;
|
||||||
|
while(request.size()-bodyAt<bodySize){std::size_t n=std::min(sizeof(buffer),bodySize-(request.size()-bodyAt));ssize_t got=::recv(fd,buffer,n,0);if(got<=0)return false;request.append(buffer,static_cast<std::size_t>(got));}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::string decode(const std::string& s) { std::string o; for(std::size_t i=0;i<s.size();++i){if(s[i]=='+')o+=' ';else if(s[i]=='%'&&i+2<s.size()){char h[3]={s[i+1],s[i+2],0};o+=static_cast<char>(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<char>(c);}else if(c<32){constexpr char h[]="0123456789abcdef";o+="\\u00";o+=h[c>>4];o+=h[c&15];}else o+=static_cast<char>(c);}o+='\"';return o;}
|
||||||
|
std::map<std::string,std::string> fields(const std::string& b){std::map<std::string,std::string> 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<std::uint32_t>(st->pairedAddress);sockaddr_in addr{};socklen_t addrLen=sizeof(addr);if(getpeername(fd,reinterpret_cast<sockaddr*>(&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<std::size_t>(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<std::uint8_t,32> 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::uint64_t>(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::uint64_t>(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<std::size_t>(logoSize));logo.seekg(0);logo.read(reinterpret_cast<char*>(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<const char*>(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<unsigned char>(image[0])==0xff&&static_cast<unsigned char>(image[1])==0xd8&&static_cast<unsigned char>(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<std::streamoff>(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<std::size_t>(size),'\0');archive.seekg(0);archive.read(bytes.data(),static_cast<std::streamsize>(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<std::streamsize>(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<const char*>(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<unsigned char>(image[0])==0xff&&static_cast<unsigned char>(image[1])==0xd8&&static_cast<unsigned char>(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<const char*>(bundle.logo.data()),static_cast<std::streamsize>(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<<bundle.app<<'\n'<<bundle.singular<<'\n'<<bundle.plural<<'\n';config.flush();if(!config){std::remove(logoPath.c_str());textReply(fd,500,"Internal Server Error","Could not write branding settings to the SD card.");return;}}
|
||||||
|
logEvent(st,"branding config imported: "+std::to_string(bodySize)+" bytes");st->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<unsigned char>(b[0])==0xff&&static_cast<unsigned char>(b[1])==0xd8&&static_cast<unsigned char>(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<std::streamsize>(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<std::streamsize>(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<<app<<'\n'<<one<<'\n'<<many<<'\n';out.flush();
|
||||||
|
if(!out){out.close();logEvent(st,"branding save failed: write failed");textReply(fd,500,"Internal Server Error","Could not write branding file to the SD card.");return;}
|
||||||
|
out.close();if(!out){logEvent(st,"branding save failed: close/flush failed");textReply(fd,500,"Internal Server Error","Could not finish writing branding file to the SD card.");return;}
|
||||||
|
logEvent(st,"branding settings saved");st->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<State*>(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<sockaddr*>(&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<sockaddr*>(&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<State*>(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<State*>(impl_);return s&&s->running.load();
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
bool BrandingConfigServer::isConnected()const{
|
||||||
|
#ifdef __SWITCH__
|
||||||
|
auto*s=static_cast<State*>(impl_);if(!s||!s->connected.load())return false;const auto now=static_cast<std::uint64_t>(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<State*>(impl_);return s&&s->pending.exchange(false,std::memory_order_acq_rel);
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
#include "export.hpp"
|
||||||
|
|
||||||
|
#include <SDL.h>
|
||||||
|
#include <SDL_image.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cmath>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace sppm {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using Bytes = std::vector<std::uint8_t>;
|
||||||
|
|
||||||
|
void be32(Bytes& out, std::uint32_t value) {
|
||||||
|
out.push_back(static_cast<std::uint8_t>(value >> 24));
|
||||||
|
out.push_back(static_cast<std::uint8_t>(value >> 16));
|
||||||
|
out.push_back(static_cast<std::uint8_t>(value >> 8));
|
||||||
|
out.push_back(static_cast<std::uint8_t>(value));
|
||||||
|
}
|
||||||
|
void be16(Bytes& out, std::uint16_t value) {
|
||||||
|
out.push_back(static_cast<std::uint8_t>(value >> 8));
|
||||||
|
out.push_back(static_cast<std::uint8_t>(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<std::uint8_t>(size >> 24);
|
||||||
|
out[start + 1] = static_cast<std::uint8_t>(size >> 16);
|
||||||
|
out[start + 2] = static_cast<std::uint8_t>(size >> 8);
|
||||||
|
out[start + 3] = static_cast<std::uint8_t>(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<std::uint8_t>(flags >> 16));
|
||||||
|
out.push_back(static_cast<std::uint8_t>(flags >> 8));
|
||||||
|
out.push_back(static_cast<std::uint8_t>(flags));
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bytes mp4MovieBox(const std::vector<std::uint32_t>& sizes,
|
||||||
|
const std::vector<std::uint32_t>& offsets,
|
||||||
|
double fps) {
|
||||||
|
constexpr std::uint32_t timeScale = 1000000;
|
||||||
|
const std::uint32_t frameDelta = static_cast<std::uint32_t>(std::lround(timeScale / fps));
|
||||||
|
const std::uint32_t duration = static_cast<std::uint32_t>(std::llround(sizes.size() * static_cast<double>(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<std::uint32_t>(kWidth) << 16);
|
||||||
|
be32(out, static_cast<std::uint32_t>(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<std::uint32_t>(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<std::uint32_t>(sizes.size()));
|
||||||
|
for (auto size : sizes) be32(out, size);
|
||||||
|
boxEnd(out, stsz);
|
||||||
|
auto stco = fullBoxBegin(out, "stco"); be32(out, static_cast<std::uint32_t>(offsets.size()));
|
||||||
|
for (auto offset : offsets) be32(out, offset);
|
||||||
|
boxEnd(out, stco);
|
||||||
|
auto stss = fullBoxBegin(out, "stss"); be32(out, static_cast<std::uint32_t>(sizes.size()));
|
||||||
|
for (std::size_t i = 0; i < sizes.size(); ++i) be32(out, static_cast<std::uint32_t>(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<std::uint8_t>((source.r * alpha + result.r * (255 - alpha) + 127) / 255);
|
||||||
|
result.g = static_cast<std::uint8_t>((source.g * alpha + result.g * (255 - alpha) + 127) / 255);
|
||||||
|
result.b = static_cast<std::uint8_t>((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<std::uint32_t>(source.a) * 255U;
|
||||||
|
const std::uint32_t behindWeight =
|
||||||
|
static_cast<std::uint32_t>(result.a) * (255U - source.a);
|
||||||
|
const std::uint32_t total = sourceWeight + behindWeight;
|
||||||
|
result.r = static_cast<std::uint8_t>(
|
||||||
|
(source.r * sourceWeight + result.r * behindWeight + total / 2U) / total);
|
||||||
|
result.g = static_cast<std::uint8_t>(
|
||||||
|
(source.g * sourceWeight + result.g * behindWeight + total / 2U) / total);
|
||||||
|
result.b = static_cast<std::uint8_t>(
|
||||||
|
(source.b * sourceWeight + result.b * behindWeight + total / 2U) / total);
|
||||||
|
result.a = static_cast<std::uint8_t>((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<int>(color.r) - candidate.r;
|
||||||
|
const int dg = static_cast<int>(color.g) - candidate.g;
|
||||||
|
const int db = static_cast<int>(color.b) - candidate.b;
|
||||||
|
const std::uint32_t distance =
|
||||||
|
static_cast<std::uint32_t>(dr * dr + dg * dg + db * db);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance;
|
||||||
|
best = static_cast<std::uint8_t>(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<std::uint8_t> gifPixels(const Frame& frame,
|
||||||
|
const Palette& palette) {
|
||||||
|
std::vector<std::uint8_t> 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<std::uint32_t, std::uint8_t> 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<std::uint32_t>(color.r) << 16U) |
|
||||||
|
(static_cast<std::uint32_t>(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<std::uint8_t> data;
|
||||||
|
std::uint32_t pending = 0;
|
||||||
|
unsigned pendingBits = 0;
|
||||||
|
|
||||||
|
void code(std::uint16_t value, unsigned width) {
|
||||||
|
pending |= static_cast<std::uint32_t>(value) << pendingBits;
|
||||||
|
pendingBits += width;
|
||||||
|
while (pendingBits >= 8) {
|
||||||
|
data.push_back(static_cast<std::uint8_t>(pending & 0xFFU));
|
||||||
|
pending >>= 8U;
|
||||||
|
pendingBits -= 8U;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void finish() {
|
||||||
|
if (pendingBits != 0) data.push_back(static_cast<std::uint8_t>(pending));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> gifLzw(const std::vector<std::uint8_t>& pixels) {
|
||||||
|
constexpr std::uint16_t clearCode = 256;
|
||||||
|
constexpr std::uint16_t endCode = 257;
|
||||||
|
std::unordered_map<std::uint32_t, std::uint16_t> 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<std::uint32_t>(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<char>(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
void le16(std::ostream& out, std::uint16_t value) {
|
||||||
|
byte(out, static_cast<std::uint8_t>(value));
|
||||||
|
byte(out, static_cast<std::uint8_t>(value >> 8U));
|
||||||
|
}
|
||||||
|
|
||||||
|
void be32(std::ostream& out, std::uint32_t value) {
|
||||||
|
byte(out, static_cast<std::uint8_t>(value >> 24U));
|
||||||
|
byte(out, static_cast<std::uint8_t>(value >> 16U));
|
||||||
|
byte(out, static_cast<std::uint8_t>(value >> 8U));
|
||||||
|
byte(out, static_cast<std::uint8_t>(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t crc32(const char* type, const std::vector<std::uint8_t>& 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<std::uint8_t>(type[i]));
|
||||||
|
for (std::uint8_t value : data) update(value);
|
||||||
|
return crc ^ 0xFFFFFFFFU;
|
||||||
|
}
|
||||||
|
|
||||||
|
void pngChunk(std::ostream& out, const char* type,
|
||||||
|
const std::vector<std::uint8_t>& data) {
|
||||||
|
be32(out, static_cast<std::uint32_t>(data.size()));
|
||||||
|
out.write(type, 4);
|
||||||
|
if (!data.empty()) {
|
||||||
|
out.write(reinterpret_cast<const char*>(data.data()),
|
||||||
|
static_cast<std::streamsize>(data.size()));
|
||||||
|
}
|
||||||
|
be32(out, crc32(type, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> zlibStored(const std::vector<std::uint8_t>& raw) {
|
||||||
|
std::vector<std::uint8_t> 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<std::size_t>(65535, raw.size() - offset);
|
||||||
|
const bool final = offset + length == raw.size();
|
||||||
|
const std::uint16_t len = static_cast<std::uint16_t>(length);
|
||||||
|
const std::uint16_t inverse = static_cast<std::uint16_t>(~len);
|
||||||
|
encoded.push_back(final ? 0x01 : 0x00);
|
||||||
|
encoded.push_back(static_cast<std::uint8_t>(len));
|
||||||
|
encoded.push_back(static_cast<std::uint8_t>(len >> 8U));
|
||||||
|
encoded.push_back(static_cast<std::uint8_t>(inverse));
|
||||||
|
encoded.push_back(static_cast<std::uint8_t>(inverse >> 8U));
|
||||||
|
encoded.insert(encoded.end(), raw.begin() + static_cast<std::ptrdiff_t>(offset),
|
||||||
|
raw.begin() + static_cast<std::ptrdiff_t>(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<std::uint8_t>(adler >> 24U));
|
||||||
|
encoded.push_back(static_cast<std::uint8_t>(adler >> 16U));
|
||||||
|
encoded.push_back(static_cast<std::uint8_t>(adler >> 8U));
|
||||||
|
encoded.push_back(static_cast<std::uint8_t>(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<std::uint8_t, 19> loop = {
|
||||||
|
0x21, 0xFF, 0x0B, 'N', 'E', 'T', 'S', 'C', 'A', 'P', 'E', '2', '.', '0',
|
||||||
|
0x03, 0x01, 0x00, 0x00, 0x00};
|
||||||
|
out.write(reinterpret_cast<const char*>(loop.data()),
|
||||||
|
static_cast<std::streamsize>(loop.size()));
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < document.frames.size(); ++i) {
|
||||||
|
const double fps = frameRate(document.metadata);
|
||||||
|
const unsigned start = static_cast<unsigned>(std::llround(i * 100.0 / fps));
|
||||||
|
const unsigned end = static_cast<unsigned>(std::llround((i + 1U) * 100.0 / fps));
|
||||||
|
const std::uint16_t delay =
|
||||||
|
static_cast<std::uint16_t>(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<std::uint8_t> pixels =
|
||||||
|
gifPixels(document.frames[i], document.palette);
|
||||||
|
const std::vector<std::uint8_t> compressed = gifLzw(pixels);
|
||||||
|
for (std::size_t offset = 0; offset < compressed.size();) {
|
||||||
|
const std::size_t length =
|
||||||
|
std::min<std::size_t>(255, compressed.size() - offset);
|
||||||
|
byte(out, static_cast<std::uint8_t>(length));
|
||||||
|
out.write(reinterpret_cast<const char*>(compressed.data() + offset),
|
||||||
|
static_cast<std::streamsize>(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<std::uint8_t> raw;
|
||||||
|
raw.reserve(static_cast<std::size_t>(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<std::uint8_t, 8> signature =
|
||||||
|
{137, 80, 78, 71, 13, 10, 26, 10};
|
||||||
|
out.write(reinterpret_cast<const char*>(signature.data()),
|
||||||
|
static_cast<std::streamsize>(signature.size()));
|
||||||
|
const std::vector<std::uint8_t> header = {
|
||||||
|
static_cast<std::uint8_t>(kWidth >> 24U),
|
||||||
|
static_cast<std::uint8_t>(kWidth >> 16U),
|
||||||
|
static_cast<std::uint8_t>(kWidth >> 8U),
|
||||||
|
static_cast<std::uint8_t>(kWidth),
|
||||||
|
static_cast<std::uint8_t>(kHeight >> 24U),
|
||||||
|
static_cast<std::uint8_t>(kHeight >> 16U),
|
||||||
|
static_cast<std::uint8_t>(kHeight >> 8U),
|
||||||
|
static_cast<std::uint8_t>(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<const char*>(ftyp.data()), static_cast<std::streamsize>(ftyp.size()));
|
||||||
|
const std::uint32_t mdatStart = static_cast<std::uint32_t>(ftyp.size());
|
||||||
|
const std::uint8_t mdatHeader[8] = {0,0,0,0,'m','d','a','t'};
|
||||||
|
out.write(reinterpret_cast<const char*>(mdatHeader), sizeof(mdatHeader));
|
||||||
|
std::vector<std::uint32_t> sampleSizes;
|
||||||
|
std::vector<std::uint32_t> sampleOffsets;
|
||||||
|
sampleSizes.reserve(document.frames.size());
|
||||||
|
sampleOffsets.reserve(document.frames.size());
|
||||||
|
|
||||||
|
constexpr int jpegCapacity = 2 * 1024 * 1024;
|
||||||
|
std::vector<std::uint8_t> jpegBuffer(static_cast<std::size_t>(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<std::uint8_t*>(surface->pixels);
|
||||||
|
for (std::size_t y = 0; y < kHeight; ++y) {
|
||||||
|
auto* row = reinterpret_cast<std::uint32_t*>(rows + y * static_cast<std::size_t>(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<std::uint64_t>(position) > UINT32_MAX) {
|
||||||
|
error = "MP4 exceeds the supported 4 GB file limit"; return false;
|
||||||
|
}
|
||||||
|
sampleOffsets.push_back(static_cast<std::uint32_t>(position));
|
||||||
|
sampleSizes.push_back(static_cast<std::uint32_t>(jpegSize));
|
||||||
|
out.write(reinterpret_cast<const char*>(jpegBuffer.data()), jpegSize);
|
||||||
|
if (!out) { error = "could not write MP4 frame data"; return false; }
|
||||||
|
}
|
||||||
|
const auto endOfMedia = out.tellp();
|
||||||
|
if (endOfMedia < 0 || static_cast<std::uint64_t>(endOfMedia) - mdatStart > UINT32_MAX) {
|
||||||
|
error = "MP4 exceeds the supported 4 GB file limit"; return false;
|
||||||
|
}
|
||||||
|
const std::uint32_t mdatSize = static_cast<std::uint32_t>(static_cast<std::uint64_t>(endOfMedia) - mdatStart);
|
||||||
|
out.seekp(mdatStart);
|
||||||
|
const std::uint8_t encodedMdatSize[4] = {
|
||||||
|
static_cast<std::uint8_t>(mdatSize >> 24), static_cast<std::uint8_t>(mdatSize >> 16),
|
||||||
|
static_cast<std::uint8_t>(mdatSize >> 8), static_cast<std::uint8_t>(mdatSize)};
|
||||||
|
out.write(reinterpret_cast<const char*>(encodedMdatSize), sizeof(encodedMdatSize));
|
||||||
|
out.seekp(endOfMedia);
|
||||||
|
const Bytes moov = mp4MovieBox(sampleSizes, sampleOffsets, fps);
|
||||||
|
out.write(reinterpret_cast<const char*>(moov.data()), static_cast<std::streamsize>(moov.size()));
|
||||||
|
out.flush();
|
||||||
|
if (!out) { error = "could not finish MP4 index"; return false; }
|
||||||
|
out.close();
|
||||||
|
error.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace sppm
|
||||||
+7796
File diff suppressed because it is too large.
Load diff
+1055
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||||
@@ -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(/<script>([\s\S]*?)<\/script>/)?.[1];
|
||||||
|
assert.ok(script, 'embedded upload script exists');
|
||||||
|
|
||||||
|
const handlers = {};
|
||||||
|
const elements = new Map();
|
||||||
|
for (const id of ['code', 'pair', 'pairStatus', 'connected', 'uploadPanel', 'track', 'file', 'send', 'status']) {
|
||||||
|
elements.set(id, {
|
||||||
|
value: '', hidden: id === 'connected' || id === 'uploadPanel',
|
||||||
|
className: 'status', textContent: '', files: [], disabled: false,
|
||||||
|
addEventListener(event, handler) { handlers[id + ':' + event] = handler; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
elements.get('track').value = '0';
|
||||||
|
elements.get('file').files = [{
|
||||||
|
name: 'short.wav', size: 4, arrayBuffer: async () => new ArrayBuffer(4),
|
||||||
|
}];
|
||||||
|
|
||||||
|
const token = 'a'.repeat(64);
|
||||||
|
const requests = [];
|
||||||
|
const fetch = async (url, options) => {
|
||||||
|
requests.push({url, options});
|
||||||
|
if (url === '/api/connect') return {ok: true, text: async () => token};
|
||||||
|
if (url === '/api/upload') return {ok: true, text: async () => 'Received'};
|
||||||
|
if (url === '/api/status') return {ok: true, text: async () => 'imported'};
|
||||||
|
throw new Error('Unexpected request: ' + url);
|
||||||
|
};
|
||||||
|
class AudioContext {
|
||||||
|
async decodeAudioData() {
|
||||||
|
return {
|
||||||
|
length: 1, sampleRate: 48000, numberOfChannels: 1,
|
||||||
|
getChannelData: () => new Float32Array([0]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async close() {}
|
||||||
|
}
|
||||||
|
vm.runInNewContext(script, {
|
||||||
|
document: {getElementById: id => elements.get(id)},
|
||||||
|
window: {AudioContext}, fetch, Blob, ArrayBuffer, DataView,
|
||||||
|
setTimeout: callback => callback(),
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.get('code').value = '123456';
|
||||||
|
handlers['code:input']();
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
assert.equal(elements.get('code').value, '123-456');
|
||||||
|
assert.equal(requests[0].url, '/api/connect');
|
||||||
|
assert.equal(requests[0].options.headers.Authorization, 'Bearer 123-456');
|
||||||
|
assert.equal(elements.get('pair').hidden, true);
|
||||||
|
assert.equal(elements.get('connected').hidden, false);
|
||||||
|
assert.match(source, /id="connected"[^>]*>Connected to Nintendo Switch<\/div>/);
|
||||||
|
assert.equal(elements.get('uploadPanel').hidden, false);
|
||||||
|
|
||||||
|
await handlers['send:click']();
|
||||||
|
assert.equal(requests[1].url, '/api/upload');
|
||||||
|
assert.equal(requests[1].options.headers.Authorization, 'Bearer ' + token);
|
||||||
|
assert.equal(requests[2].url, '/api/status');
|
||||||
|
assert.equal(requests[2].options.headers.Authorization, 'Bearer ' + token);
|
||||||
|
assert.equal(elements.get('status').className, 'status ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
#include "sppm.hpp"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void check(bool condition, const char* description) {
|
||||||
|
if (!condition) {
|
||||||
|
std::fprintf(stderr, "FAIL: %s\n", description);
|
||||||
|
++failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t crc32(const std::vector<std::uint8_t>& bytes, std::size_t start) {
|
||||||
|
std::uint32_t crc = 0xFFFFFFFFU;
|
||||||
|
for (std::size_t i = start; i < bytes.size(); ++i) {
|
||||||
|
crc ^= bytes[i];
|
||||||
|
for (int bit = 0; bit < 8; ++bit) crc = (crc >> 1) ^ ((crc & 1U) ? 0xEDB88320U : 0U);
|
||||||
|
}
|
||||||
|
return crc ^ 0xFFFFFFFFU;
|
||||||
|
}
|
||||||
|
|
||||||
|
sppm::Document example() {
|
||||||
|
sppm::Document note;
|
||||||
|
auto& m = note.metadata;
|
||||||
|
m.title = "A small Switchnote";
|
||||||
|
m.description = "Two frames and a sound effect";
|
||||||
|
m.authorName = "Ada";
|
||||||
|
m.originalAuthorName = "Ada";
|
||||||
|
m.lastEditorName = "Ada";
|
||||||
|
m.authorFsid = "0123456789ABCDEF";
|
||||||
|
m.originalAuthorFsid = m.authorFsid;
|
||||||
|
m.lastEditorFsid = m.authorFsid;
|
||||||
|
m.currentId = "1111111111111111";
|
||||||
|
m.rootId = m.currentId;
|
||||||
|
m.createdUnixSeconds = 1000;
|
||||||
|
m.modifiedUnixSeconds = 1100;
|
||||||
|
m.fps = 12;
|
||||||
|
note.frames.emplace_back();
|
||||||
|
note.frames.emplace_back();
|
||||||
|
note.frames[0].layers[0][1] = 2;
|
||||||
|
note.frames[0].layers[2][10000] = 15;
|
||||||
|
note.frames[1].layers[3][sppm::kPixelsPerLayer - 1] = 6;
|
||||||
|
for (std::size_t i = 0; i < sppm::kPixelsPerLayer; ++i) {
|
||||||
|
note.frames[1].layers[1][i] = static_cast<std::uint8_t>(i % 251);
|
||||||
|
}
|
||||||
|
note.palette[6] = {12, 34, 56, 255};
|
||||||
|
sppm::AudioTrack sound;
|
||||||
|
sound.name = "BGM";
|
||||||
|
sound.sampleRate = 8000;
|
||||||
|
sound.channels = 1;
|
||||||
|
sound.referenceFps = 8;
|
||||||
|
sound.pcm = {0, 100, -100, 32767, -32768};
|
||||||
|
note.audioTracks.push_back(sound);
|
||||||
|
sound.name = "SE1";
|
||||||
|
sound.referenceFps = 0;
|
||||||
|
sound.frameTriggers = {1};
|
||||||
|
sound.pcm = {-3, 2};
|
||||||
|
note.audioTracks.push_back(sound);
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
sppm::PublicIdentity fingerprintIdentity{};
|
||||||
|
for (std::size_t i = 0; i < fingerprintIdentity.key.size(); ++i)
|
||||||
|
fingerprintIdentity.key[i] = static_cast<std::uint8_t>(i);
|
||||||
|
for (std::size_t i = 0; i < fingerprintIdentity.deviceTag.size(); ++i)
|
||||||
|
fingerprintIdentity.deviceTag[i] = static_cast<std::uint8_t>(i + 32);
|
||||||
|
check(sppm::identityFingerprint(fingerprintIdentity) ==
|
||||||
|
"c27a4648f0825f8e69be9f507bcfa3dee5f614565a2d847c8445caebc19345df",
|
||||||
|
"Gallery ownership fingerprint is bound to the signing key");
|
||||||
|
|
||||||
|
const sppm::Palette defaults = sppm::defaultPalette();
|
||||||
|
check(defaults[6].r == 239 && defaults[6].g == 68 && defaults[6].b == 133 &&
|
||||||
|
defaults[10].r == 132 && defaults[10].g == 66 && defaults[10].b == 183,
|
||||||
|
"default Switchnote palette uses pink and purple replacements");
|
||||||
|
|
||||||
|
std::array<std::uint8_t, 32> secret{};
|
||||||
|
std::array<std::uint8_t, 16> nonce{};
|
||||||
|
for (std::size_t i = 0; i < secret.size(); ++i) secret[i] = static_cast<std::uint8_t>(i);
|
||||||
|
for (std::size_t i = 0; i < nonce.size(); ++i) nonce[i] = static_cast<std::uint8_t>(i);
|
||||||
|
check(sppm::deriveFsid(secret, nonce, "note") == "0305B0DFF9F8600F",
|
||||||
|
"HMAC-SHA256 FSID matches independent test vector");
|
||||||
|
check(sppm::deriveFsid(secret, nonce, "note") != sppm::deriveFsid(secret, nonce, "creator"),
|
||||||
|
"FSID domains are separated");
|
||||||
|
check(sppm::isValidFsid("0123456789abcdef"), "lowercase hex IDs are accepted");
|
||||||
|
check(!sppm::isValidFsid("0000000000000000"), "null lineage ID is reserved");
|
||||||
|
|
||||||
|
sppm::Document original = example();
|
||||||
|
std::string error;
|
||||||
|
std::vector<std::uint8_t> encoded;
|
||||||
|
check(sppm::serialize(original, encoded, error), "serialize valid Switchnote");
|
||||||
|
check(encoded.size() > 36, "serialized file contains payload");
|
||||||
|
sppm::Document loaded;
|
||||||
|
check(sppm::deserialize(encoded, loaded, error), "deserialize valid Switchnote");
|
||||||
|
check(loaded.metadata.title == original.metadata.title &&
|
||||||
|
loaded.metadata.currentId == original.metadata.currentId &&
|
||||||
|
loaded.metadata.createdUnixSeconds == original.metadata.createdUnixSeconds,
|
||||||
|
"metadata survives round trip");
|
||||||
|
check(loaded.frames.size() == 2 && loaded.frames[0].layers[0][1] == 2 &&
|
||||||
|
loaded.frames[0].layers[2][10000] == 15 &&
|
||||||
|
loaded.frames[1].layers[1][123456] == static_cast<std::uint8_t>(123456 % 251) &&
|
||||||
|
loaded.frames[1].layers[3][sppm::kPixelsPerLayer - 1] == 6,
|
||||||
|
"all frames and layers survive round trip");
|
||||||
|
check(loaded.palette[6].r == 12 && loaded.palette[6].g == 34 &&
|
||||||
|
loaded.audioTracks.size() == 2 && loaded.audioTracks[0].pcm[4] == -32768 &&
|
||||||
|
loaded.audioTracks[1].frameTriggers == std::vector<std::uint16_t>{1},
|
||||||
|
"palette, PCM audio, and SE timing survive round trip");
|
||||||
|
check(loaded.metadata.bgmFollowsSpeed && loaded.audioTracks[0].referenceFps == 8,
|
||||||
|
"BGM timing reference survives round trip");
|
||||||
|
check(loaded.metadata.loopPlayback, "existing Switchnotes loop by default");
|
||||||
|
sppm::Document speedOne = original;
|
||||||
|
speedOne.metadata.speed = 1;
|
||||||
|
speedOne.metadata.fps = 1;
|
||||||
|
speedOne.audioTracks[0].referenceFps = 0;
|
||||||
|
speedOne.audioTracks[0].referenceSpeed = 1;
|
||||||
|
std::vector<std::uint8_t> speedBytes;
|
||||||
|
sppm::Document speedLoaded;
|
||||||
|
check(sppm::serialize(speedOne, speedBytes, error) && sppm::deserialize(speedBytes, speedLoaded, error) &&
|
||||||
|
speedLoaded.metadata.speed == 1 && speedLoaded.audioTracks[0].referenceSpeed == 1 &&
|
||||||
|
speedLoaded.audioTracks[0].referenceFps == 0 && sppm::frameRate(speedLoaded.metadata) == 0.4985,
|
||||||
|
"DSi speed 1 and its BGM reference survive v3 round trip at half FPS");
|
||||||
|
original.metadata.bgmFollowsSpeed = false;
|
||||||
|
check(sppm::serialize(original, encoded, error) && sppm::deserialize(encoded, loaded, error) &&
|
||||||
|
!loaded.metadata.bgmFollowsSpeed && loaded.audioTracks[0].referenceFps == 8,
|
||||||
|
"loose BGM timing survives round trip");
|
||||||
|
original.metadata.loopPlayback = false;
|
||||||
|
check(sppm::serialize(original, encoded, error) && sppm::deserialize(encoded, loaded, error) &&
|
||||||
|
!loaded.metadata.loopPlayback,
|
||||||
|
"disabled playback loop survives round trip");
|
||||||
|
original = example();
|
||||||
|
check(sppm::serialize(original, encoded, error), "restore encoded sample for corruption tests");
|
||||||
|
|
||||||
|
// Version 1 had no BGM reference-speed byte. Keep already-saved notes
|
||||||
|
// readable after adding explicit speed modes in version 2.
|
||||||
|
sppm::Document oneTrack = example();
|
||||||
|
oneTrack.audioTracks.resize(1);
|
||||||
|
std::vector<std::uint8_t> oldFormat, noAudioBytes;
|
||||||
|
check(sppm::serialize(oneTrack, oldFormat, error), "make one-track compatibility fixture");
|
||||||
|
oneTrack.audioTracks.clear();
|
||||||
|
check(sppm::serialize(oneTrack, noAudioBytes, error), "find audio section boundary");
|
||||||
|
const std::size_t audioStart = noAudioBytes.size();
|
||||||
|
if (oldFormat.size() > audioStart + 2) {
|
||||||
|
oldFormat.erase(oldFormat.begin() + static_cast<std::ptrdiff_t>(audioStart + 2));
|
||||||
|
oldFormat[4] = 1;
|
||||||
|
oldFormat[28] &= static_cast<std::uint8_t>(~4U);
|
||||||
|
const std::uint64_t fileSize = oldFormat.size();
|
||||||
|
for (int i = 0; i < 8; ++i) oldFormat[8 + i] = static_cast<std::uint8_t>(fileSize >> (8 * i));
|
||||||
|
const std::uint32_t checksum = crc32(oldFormat, 36);
|
||||||
|
for (int i = 0; i < 4; ++i) oldFormat[16 + i] = static_cast<std::uint8_t>(checksum >> (8 * i));
|
||||||
|
sppm::Document v1Loaded;
|
||||||
|
check(sppm::deserialize(oldFormat, v1Loaded, error) &&
|
||||||
|
v1Loaded.metadata.bgmFollowsSpeed && v1Loaded.metadata.loopPlayback &&
|
||||||
|
v1Loaded.audioTracks[0].referenceFps == 12,
|
||||||
|
"version 1 SPPM audio remains playable");
|
||||||
|
} else check(false, "version 1 compatibility fixture has an audio section");
|
||||||
|
|
||||||
|
sppm::Document legacyPalette = example();
|
||||||
|
legacyPalette.palette[6] = {92, 224, 36, 255};
|
||||||
|
legacyPalette.palette[10] = {26, 40, 72, 255};
|
||||||
|
legacyPalette.frames[0].layers[0][20] = 6;
|
||||||
|
legacyPalette.frames[0].layers[0][21] = 10;
|
||||||
|
std::vector<std::uint8_t> oldPaletteBytes;
|
||||||
|
sppm::Document migratedPalette;
|
||||||
|
check(sppm::serialize(legacyPalette, oldPaletteBytes, error) &&
|
||||||
|
sppm::deserialize(oldPaletteBytes, migratedPalette, error) &&
|
||||||
|
migratedPalette.palette[6].r == 239 && migratedPalette.palette[6].g == 68 &&
|
||||||
|
migratedPalette.palette[10].r == 132 && migratedPalette.palette[10].b == 183 &&
|
||||||
|
migratedPalette.frames[0].layers[0][20] == 6 &&
|
||||||
|
migratedPalette.frames[0].layers[0][21] == 10,
|
||||||
|
"legacy SPPM colors migrate without changing indexed drawing data");
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> broken = encoded;
|
||||||
|
broken.back() ^= 0x55;
|
||||||
|
loaded.metadata.title = "untouched";
|
||||||
|
check(!sppm::deserialize(broken, loaded, error), "corrupt file is rejected");
|
||||||
|
check(loaded.metadata.title == "untouched", "failed load does not replace document");
|
||||||
|
broken = encoded;
|
||||||
|
broken.resize(broken.size() - 1);
|
||||||
|
check(!sppm::deserialize(broken, loaded, error), "truncated file is rejected");
|
||||||
|
broken = encoded;
|
||||||
|
broken[28] = 0xFF;
|
||||||
|
check(!sppm::deserialize(broken, loaded, error), "unknown flags are rejected");
|
||||||
|
|
||||||
|
sppm::Document fork;
|
||||||
|
check(sppm::createSpinoff(original, "Ben", "ABCDEF0123456789",
|
||||||
|
"2222222222222222", 2000, fork, error),
|
||||||
|
"allowed spin-off is created");
|
||||||
|
check(fork.metadata.authorName == "Ben" && fork.metadata.originalAuthorName == "Ada" &&
|
||||||
|
fork.metadata.originalAuthorFsid == original.metadata.originalAuthorFsid &&
|
||||||
|
fork.metadata.rootId == original.metadata.rootId &&
|
||||||
|
fork.metadata.parentId == original.metadata.currentId &&
|
||||||
|
fork.metadata.currentId == "2222222222222222" && !fork.metadata.locked,
|
||||||
|
"spin-off maintains immutable lineage and new ownership");
|
||||||
|
fork.metadata.title = "fork edit";
|
||||||
|
check(original.metadata.title != fork.metadata.title, "spin-off content is independent");
|
||||||
|
original.metadata.locked = true;
|
||||||
|
check(!sppm::createSpinoff(original, "Ben", "ABCDEF0123456789",
|
||||||
|
"3333333333333333", 2000, fork, error),
|
||||||
|
"locked Switchnote rejects spin-off");
|
||||||
|
original.metadata.locked = false;
|
||||||
|
original.metadata.allowSpinoffs = false;
|
||||||
|
check(!sppm::createSpinoff(original, "Ben", "ABCDEF0123456789",
|
||||||
|
"3333333333333333", 2000, fork, error),
|
||||||
|
"author spin-off permission is enforced");
|
||||||
|
|
||||||
|
original = example();
|
||||||
|
original.audioTracks[1].frameTriggers = {2};
|
||||||
|
check(!sppm::validate(original, error), "audio cue outside frame range is rejected");
|
||||||
|
original = example();
|
||||||
|
original.metadata.title = std::string("bad\xC0\x80", 5);
|
||||||
|
check(!sppm::validate(original, error), "invalid UTF-8 metadata is rejected");
|
||||||
|
|
||||||
|
original = example();
|
||||||
|
const auto path = std::filesystem::temp_directory_path() / "switchnote-sppm-test.sppm";
|
||||||
|
check(sppm::saveFile(path.string(), original, error), "atomic save creates a file");
|
||||||
|
sppm::Document fromDisk;
|
||||||
|
check(sppm::loadFile(path.string(), fromDisk, error), "load reads saved file");
|
||||||
|
check(fromDisk.metadata.title == original.metadata.title, "disk round trip preserves title");
|
||||||
|
original.metadata.title = "Replaced safely";
|
||||||
|
check(sppm::saveFile(path.string(), original, error), "atomic save replaces existing file");
|
||||||
|
check(sppm::loadFile(path.string(), fromDisk, error) &&
|
||||||
|
fromDisk.metadata.title == "Replaced safely", "replacement file is complete");
|
||||||
|
std::array<std::uint8_t, 32> signingKey{};
|
||||||
|
signingKey.fill(0x5A);
|
||||||
|
check(sppm::saveSignedFile(path.string(), original, signingKey, error),
|
||||||
|
"signed save creates an authenticated file");
|
||||||
|
check(sppm::loadSignedFile(path.string(), fromDisk, signingKey, error) &&
|
||||||
|
fromDisk.metadata.title == "Replaced safely", "signed file verifies and round-trips");
|
||||||
|
std::vector<std::uint8_t> signedBytes;
|
||||||
|
{
|
||||||
|
std::ifstream signedInput(path, std::ios::binary | std::ios::ate);
|
||||||
|
const auto signedSize = signedInput.tellg();
|
||||||
|
signedBytes.resize(static_cast<std::size_t>(signedSize));
|
||||||
|
signedInput.seekg(0);
|
||||||
|
signedInput.read(reinterpret_cast<char*>(signedBytes.data()), signedSize);
|
||||||
|
}
|
||||||
|
if (!signedBytes.empty()) signedBytes[40] ^= 0x01;
|
||||||
|
check(!sppm::deserializeSigned(signedBytes, fromDisk, signingKey, error),
|
||||||
|
"tampered signed file is rejected");
|
||||||
|
|
||||||
|
std::array<std::uint8_t, 16> deviceTag{};
|
||||||
|
deviceTag.fill(0x42);
|
||||||
|
sppm::SigningIdentity identity{};
|
||||||
|
check(sppm::createSigningIdentity(deviceTag, identity, error),
|
||||||
|
"public signing identity can be created");
|
||||||
|
sppm::Document publicNote = example();
|
||||||
|
const std::string publicFsid = sppm::deriveFsid(identity.publicIdentity);
|
||||||
|
publicNote.metadata.authorFsid = publicFsid;
|
||||||
|
publicNote.metadata.originalAuthorFsid = publicFsid;
|
||||||
|
publicNote.metadata.lastEditorFsid = publicFsid;
|
||||||
|
publicNote.metadata.authorIdentity = identity.publicIdentity;
|
||||||
|
publicNote.metadata.originalAuthorIdentity = identity.publicIdentity;
|
||||||
|
publicNote.metadata.lastEditorIdentity = identity.publicIdentity;
|
||||||
|
check(sppm::savePublicSignedFile(path.string(), publicNote, identity, error),
|
||||||
|
"public-key signed save creates an SPPM file");
|
||||||
|
check(sppm::loadSignedFile(path.string(), fromDisk, signingKey, error) &&
|
||||||
|
fromDisk.metadata.title == publicNote.metadata.title &&
|
||||||
|
fromDisk.frames.size() == publicNote.frames.size() &&
|
||||||
|
fromDisk.frames[1].layers[3][sppm::kPixelsPerLayer - 1] == 6,
|
||||||
|
"public-key signature verifies and the saved Switchnote reopens");
|
||||||
|
signedBytes.clear();
|
||||||
|
{
|
||||||
|
std::ifstream publicInput(path, std::ios::binary | std::ios::ate);
|
||||||
|
const auto publicSize = publicInput.tellg();
|
||||||
|
if (publicSize >= 0) {
|
||||||
|
signedBytes.resize(static_cast<std::size_t>(publicSize));
|
||||||
|
publicInput.seekg(0);
|
||||||
|
publicInput.read(reinterpret_cast<char*>(signedBytes.data()), publicSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!signedBytes.empty()) signedBytes[40] ^= 0x01;
|
||||||
|
check(!sppm::deserializeSigned(signedBytes, fromDisk, signingKey, error),
|
||||||
|
"tampered public-key signed Switchnote is rejected");
|
||||||
|
std::error_code ignored;
|
||||||
|
std::filesystem::remove(path, ignored);
|
||||||
|
|
||||||
|
if (failures == 0) std::puts("SPPM tests passed");
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
Vendored
+813
@@ -0,0 +1,813 @@
|
|||||||
|
#include "tweetnacl.h"
|
||||||
|
#define FOR(i,n) for (i = 0;i < n;++i)
|
||||||
|
#define sv static void
|
||||||
|
|
||||||
|
typedef unsigned char u8;
|
||||||
|
/* TweetNaCl's original source predates LP64 targets. */
|
||||||
|
typedef unsigned int u32;
|
||||||
|
typedef unsigned long long u64;
|
||||||
|
typedef long long i64;
|
||||||
|
typedef i64 gf[16];
|
||||||
|
extern void randombytes(u8 *,u64);
|
||||||
|
|
||||||
|
static const u8
|
||||||
|
_0[16],
|
||||||
|
_9[32] = {9};
|
||||||
|
static const gf
|
||||||
|
gf0,
|
||||||
|
gf1 = {1},
|
||||||
|
_121665 = {0xDB41,1},
|
||||||
|
D = {0x78a3, 0x1359, 0x4dca, 0x75eb, 0xd8ab, 0x4141, 0x0a4d, 0x0070, 0xe898, 0x7779, 0x4079, 0x8cc7, 0xfe73, 0x2b6f, 0x6cee, 0x5203},
|
||||||
|
D2 = {0xf159, 0x26b2, 0x9b94, 0xebd6, 0xb156, 0x8283, 0x149a, 0x00e0, 0xd130, 0xeef3, 0x80f2, 0x198e, 0xfce7, 0x56df, 0xd9dc, 0x2406},
|
||||||
|
X = {0xd51a, 0x8f25, 0x2d60, 0xc956, 0xa7b2, 0x9525, 0xc760, 0x692c, 0xdc5c, 0xfdd6, 0xe231, 0xc0a4, 0x53fe, 0xcd6e, 0x36d3, 0x2169},
|
||||||
|
Y = {0x6658, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666},
|
||||||
|
I = {0xa0b0, 0x4a0e, 0x1b27, 0xc4ee, 0xe478, 0xad2f, 0x1806, 0x2f43, 0xd7a7, 0x3dfb, 0x0099, 0x2b4d, 0xdf0b, 0x4fc1, 0x2480, 0x2b83};
|
||||||
|
|
||||||
|
static u32 L32(u32 x,int c) { return (x << c) | ((x&0xffffffff) >> (32 - c)); }
|
||||||
|
|
||||||
|
static u32 ld32(const u8 *x)
|
||||||
|
{
|
||||||
|
u32 u = x[3];
|
||||||
|
u = (u<<8)|x[2];
|
||||||
|
u = (u<<8)|x[1];
|
||||||
|
return (u<<8)|x[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static u64 dl64(const u8 *x)
|
||||||
|
{
|
||||||
|
u64 i,u=0;
|
||||||
|
FOR(i,8) u=(u<<8)|x[i];
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
sv st32(u8 *x,u32 u)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
FOR(i,4) { x[i] = u; u >>= 8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sv ts64(u8 *x,u64 u)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
for (i = 7;i >= 0;--i) { x[i] = u; u >>= 8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static int vn(const u8 *x,const u8 *y,int n)
|
||||||
|
{
|
||||||
|
u32 i,d = 0;
|
||||||
|
FOR(i,n) d |= x[i]^y[i];
|
||||||
|
return (1 & ((d - 1) >> 8)) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_verify_16(const u8 *x,const u8 *y)
|
||||||
|
{
|
||||||
|
return vn(x,y,16);
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_verify_32(const u8 *x,const u8 *y)
|
||||||
|
{
|
||||||
|
return vn(x,y,32);
|
||||||
|
}
|
||||||
|
|
||||||
|
sv core(u8 *out,const u8 *in,const u8 *k,const u8 *c,int h)
|
||||||
|
{
|
||||||
|
u32 w[16],x[16],y[16],t[4];
|
||||||
|
int i,j,m;
|
||||||
|
|
||||||
|
FOR(i,4) {
|
||||||
|
x[5*i] = ld32(c+4*i);
|
||||||
|
x[1+i] = ld32(k+4*i);
|
||||||
|
x[6+i] = ld32(in+4*i);
|
||||||
|
x[11+i] = ld32(k+16+4*i);
|
||||||
|
}
|
||||||
|
|
||||||
|
FOR(i,16) y[i] = x[i];
|
||||||
|
|
||||||
|
FOR(i,20) {
|
||||||
|
FOR(j,4) {
|
||||||
|
FOR(m,4) t[m] = x[(5*j+4*m)%16];
|
||||||
|
t[1] ^= L32(t[0]+t[3], 7);
|
||||||
|
t[2] ^= L32(t[1]+t[0], 9);
|
||||||
|
t[3] ^= L32(t[2]+t[1],13);
|
||||||
|
t[0] ^= L32(t[3]+t[2],18);
|
||||||
|
FOR(m,4) w[4*j+(j+m)%4] = t[m];
|
||||||
|
}
|
||||||
|
FOR(m,16) x[m] = w[m];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h) {
|
||||||
|
FOR(i,16) x[i] += y[i];
|
||||||
|
FOR(i,4) {
|
||||||
|
x[5*i] -= ld32(c+4*i);
|
||||||
|
x[6+i] -= ld32(in+4*i);
|
||||||
|
}
|
||||||
|
FOR(i,4) {
|
||||||
|
st32(out+4*i,x[5*i]);
|
||||||
|
st32(out+16+4*i,x[6+i]);
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
FOR(i,16) st32(out + 4 * i,x[i] + y[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_core_salsa20(u8 *out,const u8 *in,const u8 *k,const u8 *c)
|
||||||
|
{
|
||||||
|
core(out,in,k,c,0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_core_hsalsa20(u8 *out,const u8 *in,const u8 *k,const u8 *c)
|
||||||
|
{
|
||||||
|
core(out,in,k,c,1);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const u8 sigma[16] = "expand 32-byte k";
|
||||||
|
|
||||||
|
int crypto_stream_salsa20_xor(u8 *c,const u8 *m,u64 b,const u8 *n,const u8 *k)
|
||||||
|
{
|
||||||
|
u8 z[16],x[64];
|
||||||
|
u32 u,i;
|
||||||
|
if (!b) return 0;
|
||||||
|
FOR(i,16) z[i] = 0;
|
||||||
|
FOR(i,8) z[i] = n[i];
|
||||||
|
while (b >= 64) {
|
||||||
|
crypto_core_salsa20(x,z,k,sigma);
|
||||||
|
FOR(i,64) c[i] = (m?m[i]:0) ^ x[i];
|
||||||
|
u = 1;
|
||||||
|
for (i = 8;i < 16;++i) {
|
||||||
|
u += (u32) z[i];
|
||||||
|
z[i] = u;
|
||||||
|
u >>= 8;
|
||||||
|
}
|
||||||
|
b -= 64;
|
||||||
|
c += 64;
|
||||||
|
if (m) m += 64;
|
||||||
|
}
|
||||||
|
if (b) {
|
||||||
|
crypto_core_salsa20(x,z,k,sigma);
|
||||||
|
FOR(i,b) c[i] = (m?m[i]:0) ^ x[i];
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_stream_salsa20(u8 *c,u64 d,const u8 *n,const u8 *k)
|
||||||
|
{
|
||||||
|
return crypto_stream_salsa20_xor(c,0,d,n,k);
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_stream(u8 *c,u64 d,const u8 *n,const u8 *k)
|
||||||
|
{
|
||||||
|
u8 s[32];
|
||||||
|
crypto_core_hsalsa20(s,n,k,sigma);
|
||||||
|
return crypto_stream_salsa20(c,d,n+16,s);
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_stream_xor(u8 *c,const u8 *m,u64 d,const u8 *n,const u8 *k)
|
||||||
|
{
|
||||||
|
u8 s[32];
|
||||||
|
crypto_core_hsalsa20(s,n,k,sigma);
|
||||||
|
return crypto_stream_salsa20_xor(c,m,d,n+16,s);
|
||||||
|
}
|
||||||
|
|
||||||
|
sv add1305(u32 *h,const u32 *c)
|
||||||
|
{
|
||||||
|
u32 j,u = 0;
|
||||||
|
FOR(j,17) {
|
||||||
|
u += h[j] + c[j];
|
||||||
|
h[j] = u & 255;
|
||||||
|
u >>= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const u32 minusp[17] = {
|
||||||
|
5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 252
|
||||||
|
} ;
|
||||||
|
|
||||||
|
int crypto_onetimeauth(u8 *out,const u8 *m,u64 n,const u8 *k)
|
||||||
|
{
|
||||||
|
u32 s,i,j,u,x[17],r[17],h[17],c[17],g[17];
|
||||||
|
|
||||||
|
FOR(j,17) r[j]=h[j]=0;
|
||||||
|
FOR(j,16) r[j]=k[j];
|
||||||
|
r[3]&=15;
|
||||||
|
r[4]&=252;
|
||||||
|
r[7]&=15;
|
||||||
|
r[8]&=252;
|
||||||
|
r[11]&=15;
|
||||||
|
r[12]&=252;
|
||||||
|
r[15]&=15;
|
||||||
|
|
||||||
|
while (n > 0) {
|
||||||
|
FOR(j,17) c[j] = 0;
|
||||||
|
for (j = 0;(j < 16) && (j < n);++j) c[j] = m[j];
|
||||||
|
c[j] = 1;
|
||||||
|
m += j; n -= j;
|
||||||
|
add1305(h,c);
|
||||||
|
FOR(i,17) {
|
||||||
|
x[i] = 0;
|
||||||
|
FOR(j,17) x[i] += h[j] * ((j <= i) ? r[i - j] : 320 * r[i + 17 - j]);
|
||||||
|
}
|
||||||
|
FOR(i,17) h[i] = x[i];
|
||||||
|
u = 0;
|
||||||
|
FOR(j,16) {
|
||||||
|
u += h[j];
|
||||||
|
h[j] = u & 255;
|
||||||
|
u >>= 8;
|
||||||
|
}
|
||||||
|
u += h[16]; h[16] = u & 3;
|
||||||
|
u = 5 * (u >> 2);
|
||||||
|
FOR(j,16) {
|
||||||
|
u += h[j];
|
||||||
|
h[j] = u & 255;
|
||||||
|
u >>= 8;
|
||||||
|
}
|
||||||
|
u += h[16]; h[16] = u;
|
||||||
|
}
|
||||||
|
|
||||||
|
FOR(j,17) g[j] = h[j];
|
||||||
|
add1305(h,minusp);
|
||||||
|
s = -(h[16] >> 7);
|
||||||
|
FOR(j,17) h[j] ^= s & (g[j] ^ h[j]);
|
||||||
|
|
||||||
|
FOR(j,16) c[j] = k[j + 16];
|
||||||
|
c[16] = 0;
|
||||||
|
add1305(h,c);
|
||||||
|
FOR(j,16) out[j] = h[j];
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_onetimeauth_verify(const u8 *h,const u8 *m,u64 n,const u8 *k)
|
||||||
|
{
|
||||||
|
u8 x[16];
|
||||||
|
crypto_onetimeauth(x,m,n,k);
|
||||||
|
return crypto_verify_16(h,x);
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_secretbox(u8 *c,const u8 *m,u64 d,const u8 *n,const u8 *k)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
if (d < 32) return -1;
|
||||||
|
crypto_stream_xor(c,m,d,n,k);
|
||||||
|
crypto_onetimeauth(c + 16,c + 32,d - 32,c);
|
||||||
|
FOR(i,16) c[i] = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_secretbox_open(u8 *m,const u8 *c,u64 d,const u8 *n,const u8 *k)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
u8 x[32];
|
||||||
|
if (d < 32) return -1;
|
||||||
|
crypto_stream(x,32,n,k);
|
||||||
|
if (crypto_onetimeauth_verify(c + 16,c + 32,d - 32,x) != 0) return -1;
|
||||||
|
crypto_stream_xor(m,c,d,n,k);
|
||||||
|
FOR(i,32) m[i] = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
sv set25519(gf r, const gf a)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
FOR(i,16) r[i]=a[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
sv car25519(gf o)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
i64 c;
|
||||||
|
FOR(i,16) {
|
||||||
|
o[i]+=(1LL<<16);
|
||||||
|
c=o[i]>>16;
|
||||||
|
o[(i+1)*(i<15)]+=c-1+37*(c-1)*(i==15);
|
||||||
|
o[i]-=c<<16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sv sel25519(gf p,gf q,int b)
|
||||||
|
{
|
||||||
|
i64 t,i,c=~(b-1);
|
||||||
|
FOR(i,16) {
|
||||||
|
t= c&(p[i]^q[i]);
|
||||||
|
p[i]^=t;
|
||||||
|
q[i]^=t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sv pack25519(u8 *o,const gf n)
|
||||||
|
{
|
||||||
|
int i,j,b;
|
||||||
|
gf m,t;
|
||||||
|
FOR(i,16) t[i]=n[i];
|
||||||
|
car25519(t);
|
||||||
|
car25519(t);
|
||||||
|
car25519(t);
|
||||||
|
FOR(j,2) {
|
||||||
|
m[0]=t[0]-0xffed;
|
||||||
|
for(i=1;i<15;i++) {
|
||||||
|
m[i]=t[i]-0xffff-((m[i-1]>>16)&1);
|
||||||
|
m[i-1]&=0xffff;
|
||||||
|
}
|
||||||
|
m[15]=t[15]-0x7fff-((m[14]>>16)&1);
|
||||||
|
b=(m[15]>>16)&1;
|
||||||
|
m[14]&=0xffff;
|
||||||
|
sel25519(t,m,1-b);
|
||||||
|
}
|
||||||
|
FOR(i,16) {
|
||||||
|
o[2*i]=t[i]&0xff;
|
||||||
|
o[2*i+1]=t[i]>>8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int neq25519(const gf a, const gf b)
|
||||||
|
{
|
||||||
|
u8 c[32],d[32];
|
||||||
|
pack25519(c,a);
|
||||||
|
pack25519(d,b);
|
||||||
|
return crypto_verify_32(c,d);
|
||||||
|
}
|
||||||
|
|
||||||
|
static u8 par25519(const gf a)
|
||||||
|
{
|
||||||
|
u8 d[32];
|
||||||
|
pack25519(d,a);
|
||||||
|
return d[0]&1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sv unpack25519(gf o, const u8 *n)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
FOR(i,16) o[i]=n[2*i]+((i64)n[2*i+1]<<8);
|
||||||
|
o[15]&=0x7fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
sv A(gf o,const gf a,const gf b)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
FOR(i,16) o[i]=a[i]+b[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
sv Z(gf o,const gf a,const gf b)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
FOR(i,16) o[i]=a[i]-b[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
sv M(gf o,const gf a,const gf b)
|
||||||
|
{
|
||||||
|
i64 i,j,t[31];
|
||||||
|
FOR(i,31) t[i]=0;
|
||||||
|
FOR(i,16) FOR(j,16) t[i+j]+=a[i]*b[j];
|
||||||
|
FOR(i,15) t[i]+=38*t[i+16];
|
||||||
|
FOR(i,16) o[i]=t[i];
|
||||||
|
car25519(o);
|
||||||
|
car25519(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
sv S(gf o,const gf a)
|
||||||
|
{
|
||||||
|
M(o,a,a);
|
||||||
|
}
|
||||||
|
|
||||||
|
sv inv25519(gf o,const gf i)
|
||||||
|
{
|
||||||
|
gf c;
|
||||||
|
int a;
|
||||||
|
FOR(a,16) c[a]=i[a];
|
||||||
|
for(a=253;a>=0;a--) {
|
||||||
|
S(c,c);
|
||||||
|
if(a!=2&&a!=4) M(c,c,i);
|
||||||
|
}
|
||||||
|
FOR(a,16) o[a]=c[a];
|
||||||
|
}
|
||||||
|
|
||||||
|
sv pow2523(gf o,const gf i)
|
||||||
|
{
|
||||||
|
gf c;
|
||||||
|
int a;
|
||||||
|
FOR(a,16) c[a]=i[a];
|
||||||
|
for(a=250;a>=0;a--) {
|
||||||
|
S(c,c);
|
||||||
|
if(a!=1) M(c,c,i);
|
||||||
|
}
|
||||||
|
FOR(a,16) o[a]=c[a];
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_scalarmult(u8 *q,const u8 *n,const u8 *p)
|
||||||
|
{
|
||||||
|
u8 z[32];
|
||||||
|
i64 x[80],r,i;
|
||||||
|
gf a,b,c,d,e,f;
|
||||||
|
FOR(i,31) z[i]=n[i];
|
||||||
|
z[31]=(n[31]&127)|64;
|
||||||
|
z[0]&=248;
|
||||||
|
unpack25519(x,p);
|
||||||
|
FOR(i,16) {
|
||||||
|
b[i]=x[i];
|
||||||
|
d[i]=a[i]=c[i]=0;
|
||||||
|
}
|
||||||
|
a[0]=d[0]=1;
|
||||||
|
for(i=254;i>=0;--i) {
|
||||||
|
r=(z[i>>3]>>(i&7))&1;
|
||||||
|
sel25519(a,b,r);
|
||||||
|
sel25519(c,d,r);
|
||||||
|
A(e,a,c);
|
||||||
|
Z(a,a,c);
|
||||||
|
A(c,b,d);
|
||||||
|
Z(b,b,d);
|
||||||
|
S(d,e);
|
||||||
|
S(f,a);
|
||||||
|
M(a,c,a);
|
||||||
|
M(c,b,e);
|
||||||
|
A(e,a,c);
|
||||||
|
Z(a,a,c);
|
||||||
|
S(b,a);
|
||||||
|
Z(c,d,f);
|
||||||
|
M(a,c,_121665);
|
||||||
|
A(a,a,d);
|
||||||
|
M(c,c,a);
|
||||||
|
M(a,d,f);
|
||||||
|
M(d,b,x);
|
||||||
|
S(b,e);
|
||||||
|
sel25519(a,b,r);
|
||||||
|
sel25519(c,d,r);
|
||||||
|
}
|
||||||
|
FOR(i,16) {
|
||||||
|
x[i+16]=a[i];
|
||||||
|
x[i+32]=c[i];
|
||||||
|
x[i+48]=b[i];
|
||||||
|
x[i+64]=d[i];
|
||||||
|
}
|
||||||
|
inv25519(x+32,x+32);
|
||||||
|
M(x+16,x+16,x+32);
|
||||||
|
pack25519(q,x+16);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_scalarmult_base(u8 *q,const u8 *n)
|
||||||
|
{
|
||||||
|
return crypto_scalarmult(q,n,_9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
int crypto_box_keypair(u8 *y,u8 *x)
|
||||||
|
{
|
||||||
|
randombytes(x,32);
|
||||||
|
return crypto_scalarmult_base(y,x);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
int crypto_box_beforenm(u8 *k,const u8 *y,const u8 *x)
|
||||||
|
{
|
||||||
|
u8 s[32];
|
||||||
|
crypto_scalarmult(s,x,y);
|
||||||
|
return crypto_core_hsalsa20(k,_0,s,sigma);
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_box_afternm(u8 *c,const u8 *m,u64 d,const u8 *n,const u8 *k)
|
||||||
|
{
|
||||||
|
return crypto_secretbox(c,m,d,n,k);
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_box_open_afternm(u8 *m,const u8 *c,u64 d,const u8 *n,const u8 *k)
|
||||||
|
{
|
||||||
|
return crypto_secretbox_open(m,c,d,n,k);
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_box(u8 *c,const u8 *m,u64 d,const u8 *n,const u8 *y,const u8 *x)
|
||||||
|
{
|
||||||
|
u8 k[32];
|
||||||
|
crypto_box_beforenm(k,y,x);
|
||||||
|
return crypto_box_afternm(c,m,d,n,k);
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_box_open(u8 *m,const u8 *c,u64 d,const u8 *n,const u8 *y,const u8 *x)
|
||||||
|
{
|
||||||
|
u8 k[32];
|
||||||
|
crypto_box_beforenm(k,y,x);
|
||||||
|
return crypto_box_open_afternm(m,c,d,n,k);
|
||||||
|
}
|
||||||
|
|
||||||
|
static u64 R(u64 x,int c) { return (x >> c) | (x << (64 - c)); }
|
||||||
|
static u64 Ch(u64 x,u64 y,u64 z) { return (x & y) ^ (~x & z); }
|
||||||
|
static u64 Maj(u64 x,u64 y,u64 z) { return (x & y) ^ (x & z) ^ (y & z); }
|
||||||
|
static u64 Sigma0(u64 x) { return R(x,28) ^ R(x,34) ^ R(x,39); }
|
||||||
|
static u64 Sigma1(u64 x) { return R(x,14) ^ R(x,18) ^ R(x,41); }
|
||||||
|
static u64 sigma0(u64 x) { return R(x, 1) ^ R(x, 8) ^ (x >> 7); }
|
||||||
|
static u64 sigma1(u64 x) { return R(x,19) ^ R(x,61) ^ (x >> 6); }
|
||||||
|
|
||||||
|
static const u64 K[80] =
|
||||||
|
{
|
||||||
|
0x428a2f98d728ae22ULL, 0x7137449123ef65cdULL, 0xb5c0fbcfec4d3b2fULL, 0xe9b5dba58189dbbcULL,
|
||||||
|
0x3956c25bf348b538ULL, 0x59f111f1b605d019ULL, 0x923f82a4af194f9bULL, 0xab1c5ed5da6d8118ULL,
|
||||||
|
0xd807aa98a3030242ULL, 0x12835b0145706fbeULL, 0x243185be4ee4b28cULL, 0x550c7dc3d5ffb4e2ULL,
|
||||||
|
0x72be5d74f27b896fULL, 0x80deb1fe3b1696b1ULL, 0x9bdc06a725c71235ULL, 0xc19bf174cf692694ULL,
|
||||||
|
0xe49b69c19ef14ad2ULL, 0xefbe4786384f25e3ULL, 0x0fc19dc68b8cd5b5ULL, 0x240ca1cc77ac9c65ULL,
|
||||||
|
0x2de92c6f592b0275ULL, 0x4a7484aa6ea6e483ULL, 0x5cb0a9dcbd41fbd4ULL, 0x76f988da831153b5ULL,
|
||||||
|
0x983e5152ee66dfabULL, 0xa831c66d2db43210ULL, 0xb00327c898fb213fULL, 0xbf597fc7beef0ee4ULL,
|
||||||
|
0xc6e00bf33da88fc2ULL, 0xd5a79147930aa725ULL, 0x06ca6351e003826fULL, 0x142929670a0e6e70ULL,
|
||||||
|
0x27b70a8546d22ffcULL, 0x2e1b21385c26c926ULL, 0x4d2c6dfc5ac42aedULL, 0x53380d139d95b3dfULL,
|
||||||
|
0x650a73548baf63deULL, 0x766a0abb3c77b2a8ULL, 0x81c2c92e47edaee6ULL, 0x92722c851482353bULL,
|
||||||
|
0xa2bfe8a14cf10364ULL, 0xa81a664bbc423001ULL, 0xc24b8b70d0f89791ULL, 0xc76c51a30654be30ULL,
|
||||||
|
0xd192e819d6ef5218ULL, 0xd69906245565a910ULL, 0xf40e35855771202aULL, 0x106aa07032bbd1b8ULL,
|
||||||
|
0x19a4c116b8d2d0c8ULL, 0x1e376c085141ab53ULL, 0x2748774cdf8eeb99ULL, 0x34b0bcb5e19b48a8ULL,
|
||||||
|
0x391c0cb3c5c95a63ULL, 0x4ed8aa4ae3418acbULL, 0x5b9cca4f7763e373ULL, 0x682e6ff3d6b2b8a3ULL,
|
||||||
|
0x748f82ee5defb2fcULL, 0x78a5636f43172f60ULL, 0x84c87814a1f0ab72ULL, 0x8cc702081a6439ecULL,
|
||||||
|
0x90befffa23631e28ULL, 0xa4506cebde82bde9ULL, 0xbef9a3f7b2c67915ULL, 0xc67178f2e372532bULL,
|
||||||
|
0xca273eceea26619cULL, 0xd186b8c721c0c207ULL, 0xeada7dd6cde0eb1eULL, 0xf57d4f7fee6ed178ULL,
|
||||||
|
0x06f067aa72176fbaULL, 0x0a637dc5a2c898a6ULL, 0x113f9804bef90daeULL, 0x1b710b35131c471bULL,
|
||||||
|
0x28db77f523047d84ULL, 0x32caab7b40c72493ULL, 0x3c9ebe0a15c9bebcULL, 0x431d67c49c100d4cULL,
|
||||||
|
0x4cc5d4becb3e42b6ULL, 0x597f299cfc657e2aULL, 0x5fcb6fab3ad6faecULL, 0x6c44198c4a475817ULL
|
||||||
|
};
|
||||||
|
|
||||||
|
int crypto_hashblocks(u8 *x,const u8 *m,u64 n)
|
||||||
|
{
|
||||||
|
u64 z[8],b[8],a[8],w[16],t;
|
||||||
|
int i,j;
|
||||||
|
|
||||||
|
FOR(i,8) z[i] = a[i] = dl64(x + 8 * i);
|
||||||
|
|
||||||
|
while (n >= 128) {
|
||||||
|
FOR(i,16) w[i] = dl64(m + 8 * i);
|
||||||
|
|
||||||
|
FOR(i,80) {
|
||||||
|
FOR(j,8) b[j] = a[j];
|
||||||
|
t = a[7] + Sigma1(a[4]) + Ch(a[4],a[5],a[6]) + K[i] + w[i%16];
|
||||||
|
b[7] = t + Sigma0(a[0]) + Maj(a[0],a[1],a[2]);
|
||||||
|
b[3] += t;
|
||||||
|
FOR(j,8) a[(j+1)%8] = b[j];
|
||||||
|
if (i%16 == 15)
|
||||||
|
FOR(j,16)
|
||||||
|
w[j] += w[(j+9)%16] + sigma0(w[(j+1)%16]) + sigma1(w[(j+14)%16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
FOR(i,8) { a[i] += z[i]; z[i] = a[i]; }
|
||||||
|
|
||||||
|
m += 128;
|
||||||
|
n -= 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
FOR(i,8) ts64(x+8*i,z[i]);
|
||||||
|
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const u8 iv[64] = {
|
||||||
|
0x6a,0x09,0xe6,0x67,0xf3,0xbc,0xc9,0x08,
|
||||||
|
0xbb,0x67,0xae,0x85,0x84,0xca,0xa7,0x3b,
|
||||||
|
0x3c,0x6e,0xf3,0x72,0xfe,0x94,0xf8,0x2b,
|
||||||
|
0xa5,0x4f,0xf5,0x3a,0x5f,0x1d,0x36,0xf1,
|
||||||
|
0x51,0x0e,0x52,0x7f,0xad,0xe6,0x82,0xd1,
|
||||||
|
0x9b,0x05,0x68,0x8c,0x2b,0x3e,0x6c,0x1f,
|
||||||
|
0x1f,0x83,0xd9,0xab,0xfb,0x41,0xbd,0x6b,
|
||||||
|
0x5b,0xe0,0xcd,0x19,0x13,0x7e,0x21,0x79
|
||||||
|
} ;
|
||||||
|
|
||||||
|
int crypto_hash(u8 *out,const u8 *m,u64 n)
|
||||||
|
{
|
||||||
|
u8 h[64],x[256];
|
||||||
|
u64 i,b = n;
|
||||||
|
|
||||||
|
FOR(i,64) h[i] = iv[i];
|
||||||
|
|
||||||
|
crypto_hashblocks(h,m,n);
|
||||||
|
m += n;
|
||||||
|
n &= 127;
|
||||||
|
m -= n;
|
||||||
|
|
||||||
|
FOR(i,256) x[i] = 0;
|
||||||
|
FOR(i,n) x[i] = m[i];
|
||||||
|
x[n] = 128;
|
||||||
|
|
||||||
|
n = 256-128*(n<112);
|
||||||
|
x[n-9] = b >> 61;
|
||||||
|
ts64(x+n-8,b<<3);
|
||||||
|
crypto_hashblocks(h,x,n);
|
||||||
|
|
||||||
|
FOR(i,64) out[i] = h[i];
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
sv add(gf p[4],gf q[4])
|
||||||
|
{
|
||||||
|
gf a,b,c,d,t,e,f,g,h;
|
||||||
|
|
||||||
|
Z(a, p[1], p[0]);
|
||||||
|
Z(t, q[1], q[0]);
|
||||||
|
M(a, a, t);
|
||||||
|
A(b, p[0], p[1]);
|
||||||
|
A(t, q[0], q[1]);
|
||||||
|
M(b, b, t);
|
||||||
|
M(c, p[3], q[3]);
|
||||||
|
M(c, c, D2);
|
||||||
|
M(d, p[2], q[2]);
|
||||||
|
A(d, d, d);
|
||||||
|
Z(e, b, a);
|
||||||
|
Z(f, d, c);
|
||||||
|
A(g, d, c);
|
||||||
|
A(h, b, a);
|
||||||
|
|
||||||
|
M(p[0], e, f);
|
||||||
|
M(p[1], h, g);
|
||||||
|
M(p[2], g, f);
|
||||||
|
M(p[3], e, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
sv cswap(gf p[4],gf q[4],u8 b)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
FOR(i,4)
|
||||||
|
sel25519(p[i],q[i],b);
|
||||||
|
}
|
||||||
|
|
||||||
|
sv pack(u8 *r,gf p[4])
|
||||||
|
{
|
||||||
|
gf tx, ty, zi;
|
||||||
|
inv25519(zi, p[2]);
|
||||||
|
M(tx, p[0], zi);
|
||||||
|
M(ty, p[1], zi);
|
||||||
|
pack25519(r, ty);
|
||||||
|
r[31] ^= par25519(tx) << 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
sv scalarmult(gf p[4],gf q[4],const u8 *s)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
set25519(p[0],gf0);
|
||||||
|
set25519(p[1],gf1);
|
||||||
|
set25519(p[2],gf1);
|
||||||
|
set25519(p[3],gf0);
|
||||||
|
for (i = 255;i >= 0;--i) {
|
||||||
|
u8 b = (s[i/8]>>(i&7))&1;
|
||||||
|
cswap(p,q,b);
|
||||||
|
add(q,p);
|
||||||
|
add(p,p);
|
||||||
|
cswap(p,q,b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sv scalarbase(gf p[4],const u8 *s)
|
||||||
|
{
|
||||||
|
gf q[4];
|
||||||
|
set25519(q[0],X);
|
||||||
|
set25519(q[1],Y);
|
||||||
|
set25519(q[2],gf1);
|
||||||
|
M(q[3],X,Y);
|
||||||
|
scalarmult(p,q,s);
|
||||||
|
}
|
||||||
|
int crypto_sign_ed25519_tweet_keypair(u8 *pk, u8 *sk)
|
||||||
|
{
|
||||||
|
u8 d[64];
|
||||||
|
gf p[4];
|
||||||
|
int i;
|
||||||
|
|
||||||
|
randombytes(sk, 32);
|
||||||
|
crypto_hash(d, sk, 32);
|
||||||
|
d[0] &= 248;
|
||||||
|
d[31] &= 127;
|
||||||
|
d[31] |= 64;
|
||||||
|
|
||||||
|
scalarbase(p,d);
|
||||||
|
pack(pk,p);
|
||||||
|
|
||||||
|
FOR(i,32) sk[32 + i] = pk[i];
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const u64 L[32] = {0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x10};
|
||||||
|
|
||||||
|
sv modL(u8 *r,i64 x[64])
|
||||||
|
{
|
||||||
|
i64 carry,i,j;
|
||||||
|
for (i = 63;i >= 32;--i) {
|
||||||
|
carry = 0;
|
||||||
|
for (j = i - 32;j < i - 12;++j) {
|
||||||
|
x[j] += carry - 16 * x[i] * L[j - (i - 32)];
|
||||||
|
carry = (x[j] + 128) >> 8;
|
||||||
|
x[j] -= carry << 8;
|
||||||
|
}
|
||||||
|
x[j] += carry;
|
||||||
|
x[i] = 0;
|
||||||
|
}
|
||||||
|
carry = 0;
|
||||||
|
FOR(j,32) {
|
||||||
|
x[j] += carry - (x[31] >> 4) * L[j];
|
||||||
|
carry = x[j] >> 8;
|
||||||
|
x[j] &= 255;
|
||||||
|
}
|
||||||
|
FOR(j,32) x[j] -= carry * L[j];
|
||||||
|
FOR(i,32) {
|
||||||
|
x[i+1] += x[i] >> 8;
|
||||||
|
r[i] = x[i] & 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sv reduce(u8 *r)
|
||||||
|
{
|
||||||
|
i64 x[64],i;
|
||||||
|
FOR(i,64) x[i] = (u64) r[i];
|
||||||
|
FOR(i,64) r[i] = 0;
|
||||||
|
modL(r,x);
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_sign(u8 *sm,u64 *smlen,const u8 *m,u64 n,const u8 *sk)
|
||||||
|
{
|
||||||
|
u8 d[64],h[64],r[64];
|
||||||
|
i64 i,j,x[64];
|
||||||
|
gf p[4];
|
||||||
|
|
||||||
|
crypto_hash(d, sk, 32);
|
||||||
|
d[0] &= 248;
|
||||||
|
d[31] &= 127;
|
||||||
|
d[31] |= 64;
|
||||||
|
|
||||||
|
*smlen = n+64;
|
||||||
|
FOR(i,n) sm[64 + i] = m[i];
|
||||||
|
FOR(i,32) sm[32 + i] = d[32 + i];
|
||||||
|
|
||||||
|
crypto_hash(r, sm+32, n+32);
|
||||||
|
reduce(r);
|
||||||
|
scalarbase(p,r);
|
||||||
|
pack(sm,p);
|
||||||
|
|
||||||
|
FOR(i,32) sm[i+32] = sk[i+32];
|
||||||
|
crypto_hash(h,sm,n + 64);
|
||||||
|
reduce(h);
|
||||||
|
|
||||||
|
FOR(i,64) x[i] = 0;
|
||||||
|
FOR(i,32) x[i] = (u64) r[i];
|
||||||
|
FOR(i,32) FOR(j,32) x[i+j] += h[i] * (u64) d[j];
|
||||||
|
modL(sm + 32,x);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int unpackneg(gf r[4],const u8 p[32])
|
||||||
|
{
|
||||||
|
gf t, chk, num, den, den2, den4, den6;
|
||||||
|
set25519(r[2],gf1);
|
||||||
|
unpack25519(r[1],p);
|
||||||
|
S(num,r[1]);
|
||||||
|
M(den,num,D);
|
||||||
|
Z(num,num,r[2]);
|
||||||
|
A(den,r[2],den);
|
||||||
|
|
||||||
|
S(den2,den);
|
||||||
|
S(den4,den2);
|
||||||
|
M(den6,den4,den2);
|
||||||
|
M(t,den6,num);
|
||||||
|
M(t,t,den);
|
||||||
|
|
||||||
|
pow2523(t,t);
|
||||||
|
M(t,t,num);
|
||||||
|
M(t,t,den);
|
||||||
|
M(t,t,den);
|
||||||
|
M(r[0],t,den);
|
||||||
|
|
||||||
|
S(chk,r[0]);
|
||||||
|
M(chk,chk,den);
|
||||||
|
if (neq25519(chk, num)) M(r[0],r[0],I);
|
||||||
|
|
||||||
|
S(chk,r[0]);
|
||||||
|
M(chk,chk,den);
|
||||||
|
if (neq25519(chk, num)) return -1;
|
||||||
|
|
||||||
|
if (par25519(r[0]) == (p[31]>>7)) Z(r[0],gf0,r[0]);
|
||||||
|
|
||||||
|
M(r[3],r[0],r[1]);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int crypto_sign_open(u8 *m,u64 *mlen,const u8 *sm,u64 n,const u8 *pk)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
u8 t[32],h[64];
|
||||||
|
gf p[4],q[4];
|
||||||
|
|
||||||
|
*mlen = -1;
|
||||||
|
if (n < 64) return -1;
|
||||||
|
|
||||||
|
if (unpackneg(q,pk)) return -1;
|
||||||
|
|
||||||
|
FOR(i,n) m[i] = sm[i];
|
||||||
|
FOR(i,32) m[i+32] = pk[i];
|
||||||
|
crypto_hash(h,m,n);
|
||||||
|
reduce(h);
|
||||||
|
scalarmult(p,q,h);
|
||||||
|
|
||||||
|
scalarbase(q,sm + 32);
|
||||||
|
add(p,q);
|
||||||
|
pack(t,p);
|
||||||
|
|
||||||
|
n -= 64;
|
||||||
|
if (crypto_verify_32(sm, t)) {
|
||||||
|
FOR(i,n) m[i] = 0;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
FOR(i,n) m[i] = sm[i + 64];
|
||||||
|
*mlen = n;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Vendored
+272
@@ -0,0 +1,272 @@
|
|||||||
|
#ifndef TWEETNACL_H
|
||||||
|
#define TWEETNACL_H
|
||||||
|
#define crypto_auth_PRIMITIVE "hmacsha512256"
|
||||||
|
#define crypto_auth crypto_auth_hmacsha512256
|
||||||
|
#define crypto_auth_verify crypto_auth_hmacsha512256_verify
|
||||||
|
#define crypto_auth_BYTES crypto_auth_hmacsha512256_BYTES
|
||||||
|
#define crypto_auth_KEYBYTES crypto_auth_hmacsha512256_KEYBYTES
|
||||||
|
#define crypto_auth_IMPLEMENTATION crypto_auth_hmacsha512256_IMPLEMENTATION
|
||||||
|
#define crypto_auth_VERSION crypto_auth_hmacsha512256_VERSION
|
||||||
|
#define crypto_auth_hmacsha512256_tweet_BYTES 32
|
||||||
|
#define crypto_auth_hmacsha512256_tweet_KEYBYTES 32
|
||||||
|
extern int crypto_auth_hmacsha512256_tweet(unsigned char *,const unsigned char *,unsigned long long,const unsigned char *);
|
||||||
|
extern int crypto_auth_hmacsha512256_tweet_verify(const unsigned char *,const unsigned char *,unsigned long long,const unsigned char *);
|
||||||
|
#define crypto_auth_hmacsha512256_tweet_VERSION "-"
|
||||||
|
#define crypto_auth_hmacsha512256 crypto_auth_hmacsha512256_tweet
|
||||||
|
#define crypto_auth_hmacsha512256_verify crypto_auth_hmacsha512256_tweet_verify
|
||||||
|
#define crypto_auth_hmacsha512256_BYTES crypto_auth_hmacsha512256_tweet_BYTES
|
||||||
|
#define crypto_auth_hmacsha512256_KEYBYTES crypto_auth_hmacsha512256_tweet_KEYBYTES
|
||||||
|
#define crypto_auth_hmacsha512256_VERSION crypto_auth_hmacsha512256_tweet_VERSION
|
||||||
|
#define crypto_auth_hmacsha512256_IMPLEMENTATION "crypto_auth/hmacsha512256/tweet"
|
||||||
|
#define crypto_box_PRIMITIVE "curve25519xsalsa20poly1305"
|
||||||
|
#define crypto_box crypto_box_curve25519xsalsa20poly1305
|
||||||
|
#define crypto_box_open crypto_box_curve25519xsalsa20poly1305_open
|
||||||
|
#define crypto_box_keypair crypto_box_curve25519xsalsa20poly1305_keypair
|
||||||
|
#define crypto_box_beforenm crypto_box_curve25519xsalsa20poly1305_beforenm
|
||||||
|
#define crypto_box_afternm crypto_box_curve25519xsalsa20poly1305_afternm
|
||||||
|
#define crypto_box_open_afternm crypto_box_curve25519xsalsa20poly1305_open_afternm
|
||||||
|
#define crypto_box_PUBLICKEYBYTES crypto_box_curve25519xsalsa20poly1305_PUBLICKEYBYTES
|
||||||
|
#define crypto_box_SECRETKEYBYTES crypto_box_curve25519xsalsa20poly1305_SECRETKEYBYTES
|
||||||
|
#define crypto_box_BEFORENMBYTES crypto_box_curve25519xsalsa20poly1305_BEFORENMBYTES
|
||||||
|
#define crypto_box_NONCEBYTES crypto_box_curve25519xsalsa20poly1305_NONCEBYTES
|
||||||
|
#define crypto_box_ZEROBYTES crypto_box_curve25519xsalsa20poly1305_ZEROBYTES
|
||||||
|
#define crypto_box_BOXZEROBYTES crypto_box_curve25519xsalsa20poly1305_BOXZEROBYTES
|
||||||
|
#define crypto_box_IMPLEMENTATION crypto_box_curve25519xsalsa20poly1305_IMPLEMENTATION
|
||||||
|
#define crypto_box_VERSION crypto_box_curve25519xsalsa20poly1305_VERSION
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_tweet_PUBLICKEYBYTES 32
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_tweet_SECRETKEYBYTES 32
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_tweet_BEFORENMBYTES 32
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_tweet_NONCEBYTES 24
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_tweet_ZEROBYTES 32
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_tweet_BOXZEROBYTES 16
|
||||||
|
extern int crypto_box_curve25519xsalsa20poly1305_tweet(unsigned char *,const unsigned char *,unsigned long long,const unsigned char *,const unsigned char *,const unsigned char *);
|
||||||
|
extern int crypto_box_curve25519xsalsa20poly1305_tweet_open(unsigned char *,const unsigned char *,unsigned long long,const unsigned char *,const unsigned char *,const unsigned char *);
|
||||||
|
extern int crypto_box_curve25519xsalsa20poly1305_tweet_keypair(unsigned char *,unsigned char *);
|
||||||
|
extern int crypto_box_curve25519xsalsa20poly1305_tweet_beforenm(unsigned char *,const unsigned char *,const unsigned char *);
|
||||||
|
extern int crypto_box_curve25519xsalsa20poly1305_tweet_afternm(unsigned char *,const unsigned char *,unsigned long long,const unsigned char *,const unsigned char *);
|
||||||
|
extern int crypto_box_curve25519xsalsa20poly1305_tweet_open_afternm(unsigned char *,const unsigned char *,unsigned long long,const unsigned char *,const unsigned char *);
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_tweet_VERSION "-"
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305 crypto_box_curve25519xsalsa20poly1305_tweet
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_open crypto_box_curve25519xsalsa20poly1305_tweet_open
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_keypair crypto_box_curve25519xsalsa20poly1305_tweet_keypair
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_beforenm crypto_box_curve25519xsalsa20poly1305_tweet_beforenm
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_afternm crypto_box_curve25519xsalsa20poly1305_tweet_afternm
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_open_afternm crypto_box_curve25519xsalsa20poly1305_tweet_open_afternm
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_PUBLICKEYBYTES crypto_box_curve25519xsalsa20poly1305_tweet_PUBLICKEYBYTES
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_SECRETKEYBYTES crypto_box_curve25519xsalsa20poly1305_tweet_SECRETKEYBYTES
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_BEFORENMBYTES crypto_box_curve25519xsalsa20poly1305_tweet_BEFORENMBYTES
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_NONCEBYTES crypto_box_curve25519xsalsa20poly1305_tweet_NONCEBYTES
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_ZEROBYTES crypto_box_curve25519xsalsa20poly1305_tweet_ZEROBYTES
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_BOXZEROBYTES crypto_box_curve25519xsalsa20poly1305_tweet_BOXZEROBYTES
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_VERSION crypto_box_curve25519xsalsa20poly1305_tweet_VERSION
|
||||||
|
#define crypto_box_curve25519xsalsa20poly1305_IMPLEMENTATION "crypto_box/curve25519xsalsa20poly1305/tweet"
|
||||||
|
#define crypto_core_PRIMITIVE "salsa20"
|
||||||
|
#define crypto_core crypto_core_salsa20
|
||||||
|
#define crypto_core_OUTPUTBYTES crypto_core_salsa20_OUTPUTBYTES
|
||||||
|
#define crypto_core_INPUTBYTES crypto_core_salsa20_INPUTBYTES
|
||||||
|
#define crypto_core_KEYBYTES crypto_core_salsa20_KEYBYTES
|
||||||
|
#define crypto_core_CONSTBYTES crypto_core_salsa20_CONSTBYTES
|
||||||
|
#define crypto_core_IMPLEMENTATION crypto_core_salsa20_IMPLEMENTATION
|
||||||
|
#define crypto_core_VERSION crypto_core_salsa20_VERSION
|
||||||
|
#define crypto_core_salsa20_tweet_OUTPUTBYTES 64
|
||||||
|
#define crypto_core_salsa20_tweet_INPUTBYTES 16
|
||||||
|
#define crypto_core_salsa20_tweet_KEYBYTES 32
|
||||||
|
#define crypto_core_salsa20_tweet_CONSTBYTES 16
|
||||||
|
extern int crypto_core_salsa20_tweet(unsigned char *,const unsigned char *,const unsigned char *,const unsigned char *);
|
||||||
|
#define crypto_core_salsa20_tweet_VERSION "-"
|
||||||
|
#define crypto_core_salsa20 crypto_core_salsa20_tweet
|
||||||
|
#define crypto_core_salsa20_OUTPUTBYTES crypto_core_salsa20_tweet_OUTPUTBYTES
|
||||||
|
#define crypto_core_salsa20_INPUTBYTES crypto_core_salsa20_tweet_INPUTBYTES
|
||||||
|
#define crypto_core_salsa20_KEYBYTES crypto_core_salsa20_tweet_KEYBYTES
|
||||||
|
#define crypto_core_salsa20_CONSTBYTES crypto_core_salsa20_tweet_CONSTBYTES
|
||||||
|
#define crypto_core_salsa20_VERSION crypto_core_salsa20_tweet_VERSION
|
||||||
|
#define crypto_core_salsa20_IMPLEMENTATION "crypto_core/salsa20/tweet"
|
||||||
|
#define crypto_core_hsalsa20_tweet_OUTPUTBYTES 32
|
||||||
|
#define crypto_core_hsalsa20_tweet_INPUTBYTES 16
|
||||||
|
#define crypto_core_hsalsa20_tweet_KEYBYTES 32
|
||||||
|
#define crypto_core_hsalsa20_tweet_CONSTBYTES 16
|
||||||
|
extern int crypto_core_hsalsa20_tweet(unsigned char *,const unsigned char *,const unsigned char *,const unsigned char *);
|
||||||
|
#define crypto_core_hsalsa20_tweet_VERSION "-"
|
||||||
|
#define crypto_core_hsalsa20 crypto_core_hsalsa20_tweet
|
||||||
|
#define crypto_core_hsalsa20_OUTPUTBYTES crypto_core_hsalsa20_tweet_OUTPUTBYTES
|
||||||
|
#define crypto_core_hsalsa20_INPUTBYTES crypto_core_hsalsa20_tweet_INPUTBYTES
|
||||||
|
#define crypto_core_hsalsa20_KEYBYTES crypto_core_hsalsa20_tweet_KEYBYTES
|
||||||
|
#define crypto_core_hsalsa20_CONSTBYTES crypto_core_hsalsa20_tweet_CONSTBYTES
|
||||||
|
#define crypto_core_hsalsa20_VERSION crypto_core_hsalsa20_tweet_VERSION
|
||||||
|
#define crypto_core_hsalsa20_IMPLEMENTATION "crypto_core/hsalsa20/tweet"
|
||||||
|
#define crypto_hashblocks_PRIMITIVE "sha512"
|
||||||
|
#define crypto_hashblocks crypto_hashblocks_sha512
|
||||||
|
#define crypto_hashblocks_STATEBYTES crypto_hashblocks_sha512_STATEBYTES
|
||||||
|
#define crypto_hashblocks_BLOCKBYTES crypto_hashblocks_sha512_BLOCKBYTES
|
||||||
|
#define crypto_hashblocks_IMPLEMENTATION crypto_hashblocks_sha512_IMPLEMENTATION
|
||||||
|
#define crypto_hashblocks_VERSION crypto_hashblocks_sha512_VERSION
|
||||||
|
#define crypto_hashblocks_sha512_tweet_STATEBYTES 64
|
||||||
|
#define crypto_hashblocks_sha512_tweet_BLOCKBYTES 128
|
||||||
|
extern int crypto_hashblocks_sha512_tweet(unsigned char *,const unsigned char *,unsigned long long);
|
||||||
|
#define crypto_hashblocks_sha512_tweet_VERSION "-"
|
||||||
|
#define crypto_hashblocks_sha512 crypto_hashblocks_sha512_tweet
|
||||||
|
#define crypto_hashblocks_sha512_STATEBYTES crypto_hashblocks_sha512_tweet_STATEBYTES
|
||||||
|
#define crypto_hashblocks_sha512_BLOCKBYTES crypto_hashblocks_sha512_tweet_BLOCKBYTES
|
||||||
|
#define crypto_hashblocks_sha512_VERSION crypto_hashblocks_sha512_tweet_VERSION
|
||||||
|
#define crypto_hashblocks_sha512_IMPLEMENTATION "crypto_hashblocks/sha512/tweet"
|
||||||
|
#define crypto_hashblocks_sha256_tweet_STATEBYTES 32
|
||||||
|
#define crypto_hashblocks_sha256_tweet_BLOCKBYTES 64
|
||||||
|
extern int crypto_hashblocks_sha256_tweet(unsigned char *,const unsigned char *,unsigned long long);
|
||||||
|
#define crypto_hashblocks_sha256_tweet_VERSION "-"
|
||||||
|
#define crypto_hashblocks_sha256 crypto_hashblocks_sha256_tweet
|
||||||
|
#define crypto_hashblocks_sha256_STATEBYTES crypto_hashblocks_sha256_tweet_STATEBYTES
|
||||||
|
#define crypto_hashblocks_sha256_BLOCKBYTES crypto_hashblocks_sha256_tweet_BLOCKBYTES
|
||||||
|
#define crypto_hashblocks_sha256_VERSION crypto_hashblocks_sha256_tweet_VERSION
|
||||||
|
#define crypto_hashblocks_sha256_IMPLEMENTATION "crypto_hashblocks/sha256/tweet"
|
||||||
|
#define crypto_hash_PRIMITIVE "sha512"
|
||||||
|
#define crypto_hash crypto_hash_sha512
|
||||||
|
#define crypto_hash_BYTES crypto_hash_sha512_BYTES
|
||||||
|
#define crypto_hash_IMPLEMENTATION crypto_hash_sha512_IMPLEMENTATION
|
||||||
|
#define crypto_hash_VERSION crypto_hash_sha512_VERSION
|
||||||
|
#define crypto_hash_sha512_tweet_BYTES 64
|
||||||
|
extern int crypto_hash_sha512_tweet(unsigned char *,const unsigned char *,unsigned long long);
|
||||||
|
#define crypto_hash_sha512_tweet_VERSION "-"
|
||||||
|
#define crypto_hash_sha512 crypto_hash_sha512_tweet
|
||||||
|
#define crypto_hash_sha512_BYTES crypto_hash_sha512_tweet_BYTES
|
||||||
|
#define crypto_hash_sha512_VERSION crypto_hash_sha512_tweet_VERSION
|
||||||
|
#define crypto_hash_sha512_IMPLEMENTATION "crypto_hash/sha512/tweet"
|
||||||
|
#define crypto_hash_sha256_tweet_BYTES 32
|
||||||
|
extern int crypto_hash_sha256_tweet(unsigned char *,const unsigned char *,unsigned long long);
|
||||||
|
#define crypto_hash_sha256_tweet_VERSION "-"
|
||||||
|
#define crypto_hash_sha256 crypto_hash_sha256_tweet
|
||||||
|
#define crypto_hash_sha256_BYTES crypto_hash_sha256_tweet_BYTES
|
||||||
|
#define crypto_hash_sha256_VERSION crypto_hash_sha256_tweet_VERSION
|
||||||
|
#define crypto_hash_sha256_IMPLEMENTATION "crypto_hash/sha256/tweet"
|
||||||
|
#define crypto_onetimeauth_PRIMITIVE "poly1305"
|
||||||
|
#define crypto_onetimeauth crypto_onetimeauth_poly1305
|
||||||
|
#define crypto_onetimeauth_verify crypto_onetimeauth_poly1305_verify
|
||||||
|
#define crypto_onetimeauth_BYTES crypto_onetimeauth_poly1305_BYTES
|
||||||
|
#define crypto_onetimeauth_KEYBYTES crypto_onetimeauth_poly1305_KEYBYTES
|
||||||
|
#define crypto_onetimeauth_IMPLEMENTATION crypto_onetimeauth_poly1305_IMPLEMENTATION
|
||||||
|
#define crypto_onetimeauth_VERSION crypto_onetimeauth_poly1305_VERSION
|
||||||
|
#define crypto_onetimeauth_poly1305_tweet_BYTES 16
|
||||||
|
#define crypto_onetimeauth_poly1305_tweet_KEYBYTES 32
|
||||||
|
extern int crypto_onetimeauth_poly1305_tweet(unsigned char *,const unsigned char *,unsigned long long,const unsigned char *);
|
||||||
|
extern int crypto_onetimeauth_poly1305_tweet_verify(const unsigned char *,const unsigned char *,unsigned long long,const unsigned char *);
|
||||||
|
#define crypto_onetimeauth_poly1305_tweet_VERSION "-"
|
||||||
|
#define crypto_onetimeauth_poly1305 crypto_onetimeauth_poly1305_tweet
|
||||||
|
#define crypto_onetimeauth_poly1305_verify crypto_onetimeauth_poly1305_tweet_verify
|
||||||
|
#define crypto_onetimeauth_poly1305_BYTES crypto_onetimeauth_poly1305_tweet_BYTES
|
||||||
|
#define crypto_onetimeauth_poly1305_KEYBYTES crypto_onetimeauth_poly1305_tweet_KEYBYTES
|
||||||
|
#define crypto_onetimeauth_poly1305_VERSION crypto_onetimeauth_poly1305_tweet_VERSION
|
||||||
|
#define crypto_onetimeauth_poly1305_IMPLEMENTATION "crypto_onetimeauth/poly1305/tweet"
|
||||||
|
#define crypto_scalarmult_PRIMITIVE "curve25519"
|
||||||
|
#define crypto_scalarmult crypto_scalarmult_curve25519
|
||||||
|
#define crypto_scalarmult_base crypto_scalarmult_curve25519_base
|
||||||
|
#define crypto_scalarmult_BYTES crypto_scalarmult_curve25519_BYTES
|
||||||
|
#define crypto_scalarmult_SCALARBYTES crypto_scalarmult_curve25519_SCALARBYTES
|
||||||
|
#define crypto_scalarmult_IMPLEMENTATION crypto_scalarmult_curve25519_IMPLEMENTATION
|
||||||
|
#define crypto_scalarmult_VERSION crypto_scalarmult_curve25519_VERSION
|
||||||
|
#define crypto_scalarmult_curve25519_tweet_BYTES 32
|
||||||
|
#define crypto_scalarmult_curve25519_tweet_SCALARBYTES 32
|
||||||
|
extern int crypto_scalarmult_curve25519_tweet(unsigned char *,const unsigned char *,const unsigned char *);
|
||||||
|
extern int crypto_scalarmult_curve25519_tweet_base(unsigned char *,const unsigned char *);
|
||||||
|
#define crypto_scalarmult_curve25519_tweet_VERSION "-"
|
||||||
|
#define crypto_scalarmult_curve25519 crypto_scalarmult_curve25519_tweet
|
||||||
|
#define crypto_scalarmult_curve25519_base crypto_scalarmult_curve25519_tweet_base
|
||||||
|
#define crypto_scalarmult_curve25519_BYTES crypto_scalarmult_curve25519_tweet_BYTES
|
||||||
|
#define crypto_scalarmult_curve25519_SCALARBYTES crypto_scalarmult_curve25519_tweet_SCALARBYTES
|
||||||
|
#define crypto_scalarmult_curve25519_VERSION crypto_scalarmult_curve25519_tweet_VERSION
|
||||||
|
#define crypto_scalarmult_curve25519_IMPLEMENTATION "crypto_scalarmult/curve25519/tweet"
|
||||||
|
#define crypto_secretbox_PRIMITIVE "xsalsa20poly1305"
|
||||||
|
#define crypto_secretbox crypto_secretbox_xsalsa20poly1305
|
||||||
|
#define crypto_secretbox_open crypto_secretbox_xsalsa20poly1305_open
|
||||||
|
#define crypto_secretbox_KEYBYTES crypto_secretbox_xsalsa20poly1305_KEYBYTES
|
||||||
|
#define crypto_secretbox_NONCEBYTES crypto_secretbox_xsalsa20poly1305_NONCEBYTES
|
||||||
|
#define crypto_secretbox_ZEROBYTES crypto_secretbox_xsalsa20poly1305_ZEROBYTES
|
||||||
|
#define crypto_secretbox_BOXZEROBYTES crypto_secretbox_xsalsa20poly1305_BOXZEROBYTES
|
||||||
|
#define crypto_secretbox_IMPLEMENTATION crypto_secretbox_xsalsa20poly1305_IMPLEMENTATION
|
||||||
|
#define crypto_secretbox_VERSION crypto_secretbox_xsalsa20poly1305_VERSION
|
||||||
|
#define crypto_secretbox_xsalsa20poly1305_tweet_KEYBYTES 32
|
||||||
|
#define crypto_secretbox_xsalsa20poly1305_tweet_NONCEBYTES 24
|
||||||
|
#define crypto_secretbox_xsalsa20poly1305_tweet_ZEROBYTES 32
|
||||||
|
#define crypto_secretbox_xsalsa20poly1305_tweet_BOXZEROBYTES 16
|
||||||
|
extern int crypto_secretbox_xsalsa20poly1305_tweet(unsigned char *,const unsigned char *,unsigned long long,const unsigned char *,const unsigned char *);
|
||||||
|
extern int crypto_secretbox_xsalsa20poly1305_tweet_open(unsigned char *,const unsigned char *,unsigned long long,const unsigned char *,const unsigned char *);
|
||||||
|
#define crypto_secretbox_xsalsa20poly1305_tweet_VERSION "-"
|
||||||
|
#define crypto_secretbox_xsalsa20poly1305 crypto_secretbox_xsalsa20poly1305_tweet
|
||||||
|
#define crypto_secretbox_xsalsa20poly1305_open crypto_secretbox_xsalsa20poly1305_tweet_open
|
||||||
|
#define crypto_secretbox_xsalsa20poly1305_KEYBYTES crypto_secretbox_xsalsa20poly1305_tweet_KEYBYTES
|
||||||
|
#define crypto_secretbox_xsalsa20poly1305_NONCEBYTES crypto_secretbox_xsalsa20poly1305_tweet_NONCEBYTES
|
||||||
|
#define crypto_secretbox_xsalsa20poly1305_ZEROBYTES crypto_secretbox_xsalsa20poly1305_tweet_ZEROBYTES
|
||||||
|
#define crypto_secretbox_xsalsa20poly1305_BOXZEROBYTES crypto_secretbox_xsalsa20poly1305_tweet_BOXZEROBYTES
|
||||||
|
#define crypto_secretbox_xsalsa20poly1305_VERSION crypto_secretbox_xsalsa20poly1305_tweet_VERSION
|
||||||
|
#define crypto_secretbox_xsalsa20poly1305_IMPLEMENTATION "crypto_secretbox/xsalsa20poly1305/tweet"
|
||||||
|
#define crypto_sign_PRIMITIVE "ed25519"
|
||||||
|
#define crypto_sign crypto_sign_ed25519
|
||||||
|
#define crypto_sign_open crypto_sign_ed25519_open
|
||||||
|
#define crypto_sign_keypair crypto_sign_ed25519_keypair
|
||||||
|
#define crypto_sign_BYTES crypto_sign_ed25519_BYTES
|
||||||
|
#define crypto_sign_PUBLICKEYBYTES crypto_sign_ed25519_PUBLICKEYBYTES
|
||||||
|
#define crypto_sign_SECRETKEYBYTES crypto_sign_ed25519_SECRETKEYBYTES
|
||||||
|
#define crypto_sign_IMPLEMENTATION crypto_sign_ed25519_IMPLEMENTATION
|
||||||
|
#define crypto_sign_VERSION crypto_sign_ed25519_VERSION
|
||||||
|
#define crypto_sign_ed25519_tweet_BYTES 64
|
||||||
|
#define crypto_sign_ed25519_tweet_PUBLICKEYBYTES 32
|
||||||
|
#define crypto_sign_ed25519_tweet_SECRETKEYBYTES 64
|
||||||
|
extern int crypto_sign_ed25519_tweet(unsigned char *,unsigned long long *,const unsigned char *,unsigned long long,const unsigned char *);
|
||||||
|
extern int crypto_sign_ed25519_tweet_open(unsigned char *,unsigned long long *,const unsigned char *,unsigned long long,const unsigned char *);
|
||||||
|
extern int crypto_sign_ed25519_tweet_keypair(unsigned char *,unsigned char *);
|
||||||
|
#define crypto_sign_ed25519_tweet_VERSION "-"
|
||||||
|
#define crypto_sign_ed25519 crypto_sign_ed25519_tweet
|
||||||
|
#define crypto_sign_ed25519_open crypto_sign_ed25519_tweet_open
|
||||||
|
#define crypto_sign_ed25519_keypair crypto_sign_ed25519_tweet_keypair
|
||||||
|
#define crypto_sign_ed25519_BYTES crypto_sign_ed25519_tweet_BYTES
|
||||||
|
#define crypto_sign_ed25519_PUBLICKEYBYTES crypto_sign_ed25519_tweet_PUBLICKEYBYTES
|
||||||
|
#define crypto_sign_ed25519_SECRETKEYBYTES crypto_sign_ed25519_tweet_SECRETKEYBYTES
|
||||||
|
#define crypto_sign_ed25519_VERSION crypto_sign_ed25519_tweet_VERSION
|
||||||
|
#define crypto_sign_ed25519_IMPLEMENTATION "crypto_sign/ed25519/tweet"
|
||||||
|
#define crypto_stream_PRIMITIVE "xsalsa20"
|
||||||
|
#define crypto_stream crypto_stream_xsalsa20
|
||||||
|
#define crypto_stream_xor crypto_stream_xsalsa20_xor
|
||||||
|
#define crypto_stream_KEYBYTES crypto_stream_xsalsa20_KEYBYTES
|
||||||
|
#define crypto_stream_NONCEBYTES crypto_stream_xsalsa20_NONCEBYTES
|
||||||
|
#define crypto_stream_IMPLEMENTATION crypto_stream_xsalsa20_IMPLEMENTATION
|
||||||
|
#define crypto_stream_VERSION crypto_stream_xsalsa20_VERSION
|
||||||
|
#define crypto_stream_xsalsa20_tweet_KEYBYTES 32
|
||||||
|
#define crypto_stream_xsalsa20_tweet_NONCEBYTES 24
|
||||||
|
extern int crypto_stream_xsalsa20_tweet(unsigned char *,unsigned long long,const unsigned char *,const unsigned char *);
|
||||||
|
extern int crypto_stream_xsalsa20_tweet_xor(unsigned char *,const unsigned char *,unsigned long long,const unsigned char *,const unsigned char *);
|
||||||
|
#define crypto_stream_xsalsa20_tweet_VERSION "-"
|
||||||
|
#define crypto_stream_xsalsa20 crypto_stream_xsalsa20_tweet
|
||||||
|
#define crypto_stream_xsalsa20_xor crypto_stream_xsalsa20_tweet_xor
|
||||||
|
#define crypto_stream_xsalsa20_KEYBYTES crypto_stream_xsalsa20_tweet_KEYBYTES
|
||||||
|
#define crypto_stream_xsalsa20_NONCEBYTES crypto_stream_xsalsa20_tweet_NONCEBYTES
|
||||||
|
#define crypto_stream_xsalsa20_VERSION crypto_stream_xsalsa20_tweet_VERSION
|
||||||
|
#define crypto_stream_xsalsa20_IMPLEMENTATION "crypto_stream/xsalsa20/tweet"
|
||||||
|
#define crypto_stream_salsa20_tweet_KEYBYTES 32
|
||||||
|
#define crypto_stream_salsa20_tweet_NONCEBYTES 8
|
||||||
|
extern int crypto_stream_salsa20_tweet(unsigned char *,unsigned long long,const unsigned char *,const unsigned char *);
|
||||||
|
extern int crypto_stream_salsa20_tweet_xor(unsigned char *,const unsigned char *,unsigned long long,const unsigned char *,const unsigned char *);
|
||||||
|
#define crypto_stream_salsa20_tweet_VERSION "-"
|
||||||
|
#define crypto_stream_salsa20 crypto_stream_salsa20_tweet
|
||||||
|
#define crypto_stream_salsa20_xor crypto_stream_salsa20_tweet_xor
|
||||||
|
#define crypto_stream_salsa20_KEYBYTES crypto_stream_salsa20_tweet_KEYBYTES
|
||||||
|
#define crypto_stream_salsa20_NONCEBYTES crypto_stream_salsa20_tweet_NONCEBYTES
|
||||||
|
#define crypto_stream_salsa20_VERSION crypto_stream_salsa20_tweet_VERSION
|
||||||
|
#define crypto_stream_salsa20_IMPLEMENTATION "crypto_stream/salsa20/tweet"
|
||||||
|
#define crypto_verify_PRIMITIVE "16"
|
||||||
|
#define crypto_verify crypto_verify_16
|
||||||
|
#define crypto_verify_BYTES crypto_verify_16_BYTES
|
||||||
|
#define crypto_verify_IMPLEMENTATION crypto_verify_16_IMPLEMENTATION
|
||||||
|
#define crypto_verify_VERSION crypto_verify_16_VERSION
|
||||||
|
#define crypto_verify_16_tweet_BYTES 16
|
||||||
|
extern int crypto_verify_16_tweet(const unsigned char *,const unsigned char *);
|
||||||
|
#define crypto_verify_16_tweet_VERSION "-"
|
||||||
|
#define crypto_verify_16 crypto_verify_16_tweet
|
||||||
|
#define crypto_verify_16_BYTES crypto_verify_16_tweet_BYTES
|
||||||
|
#define crypto_verify_16_VERSION crypto_verify_16_tweet_VERSION
|
||||||
|
#define crypto_verify_16_IMPLEMENTATION "crypto_verify/16/tweet"
|
||||||
|
#define crypto_verify_32_tweet_BYTES 32
|
||||||
|
extern int crypto_verify_32_tweet(const unsigned char *,const unsigned char *);
|
||||||
|
#define crypto_verify_32_tweet_VERSION "-"
|
||||||
|
#define crypto_verify_32 crypto_verify_32_tweet
|
||||||
|
#define crypto_verify_32_BYTES crypto_verify_32_tweet_BYTES
|
||||||
|
#define crypto_verify_32_VERSION crypto_verify_32_tweet_VERSION
|
||||||
|
#define crypto_verify_32_IMPLEMENTATION "crypto_verify/32/tweet"
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 RexiMemo!
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
const WIDTH = 640, HEIGHT = 360, PIXELS = WIDTH * HEIGHT;
|
||||||
|
const HEADER_SIZE = 36, MAX_FILE_SIZE = 512 * 1024 * 1024;
|
||||||
|
const PUBLIC_FOOTER_SIZE = 4 + 3 * 48 + 64;
|
||||||
|
|
||||||
|
function fail(message) { throw new Error(`Invalid SPPM: ${message}`); }
|
||||||
|
|
||||||
|
function crc32(bytes) {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const byte of bytes) {
|
||||||
|
crc ^= byte;
|
||||||
|
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inputBytes(input) {
|
||||||
|
if (input instanceof Uint8Array) return input;
|
||||||
|
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
||||||
|
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
||||||
|
if (typeof Blob !== "undefined" && input instanceof Blob) return new Uint8Array(await input.arrayBuffer());
|
||||||
|
if (typeof input === "string") {
|
||||||
|
const { readFile } = await import("node:fs/promises");
|
||||||
|
return new Uint8Array(await readFile(input));
|
||||||
|
}
|
||||||
|
fail("expected a path, Blob, ArrayBuffer, or Uint8Array");
|
||||||
|
}
|
||||||
|
|
||||||
|
class Reader {
|
||||||
|
constructor(bytes, end = bytes.length) { this.bytes = bytes; this.view = new DataView(bytes.buffer, bytes.byteOffset, end); this.at = 0; this.end = end; }
|
||||||
|
need(size) { if (!Number.isSafeInteger(size) || size < 0 || size > this.end - this.at) fail("truncated payload"); }
|
||||||
|
u8() { this.need(1); return this.view.getUint8(this.at++); }
|
||||||
|
u16() { this.need(2); const v = this.view.getUint16(this.at, true); this.at += 2; return v; }
|
||||||
|
u32() { this.need(4); const v = this.view.getUint32(this.at, true); this.at += 4; return v; }
|
||||||
|
u64() { this.need(8); const v = this.view.getBigUint64(this.at, true); this.at += 8; return v <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(v) : v.toString(); }
|
||||||
|
take(size) { this.need(size); const result = this.bytes.subarray(this.at, this.at + size); this.at += size; return result; }
|
||||||
|
string(max) {
|
||||||
|
const size = this.u16(); if (size > max) fail("metadata string exceeds limit");
|
||||||
|
const bytes = this.take(size);
|
||||||
|
if (bytes.includes(0)) fail("metadata contains NUL");
|
||||||
|
try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
|
||||||
|
catch { fail("metadata is not valid UTF-8"); }
|
||||||
|
}
|
||||||
|
id(optional = false) {
|
||||||
|
const bytes = this.take(16), value = String.fromCharCode(...bytes);
|
||||||
|
if (optional && value === "0000000000000000") return "";
|
||||||
|
if (!/^[0-9A-Fa-f]{16}$/.test(value) || value === "0000000000000000") fail("invalid lineage ID");
|
||||||
|
return value.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeLayer(encoded) {
|
||||||
|
const out = new Uint8Array(PIXELS); let src = 0, dst = 0;
|
||||||
|
while (src < encoded.length) {
|
||||||
|
const command = encoded[src++], count = (command & 0x7f) + 1;
|
||||||
|
if (dst + count > PIXELS) fail("RLE layer expands beyond canvas size");
|
||||||
|
if (command & 0x80) {
|
||||||
|
if (src >= encoded.length) fail("truncated RLE repeat packet");
|
||||||
|
out.fill(encoded[src++], dst, dst + count);
|
||||||
|
} else {
|
||||||
|
if (src + count > encoded.length) fail("truncated RLE literal packet");
|
||||||
|
out.set(encoded.subarray(src, src + count), dst); src += count;
|
||||||
|
}
|
||||||
|
dst += count;
|
||||||
|
}
|
||||||
|
if (dst !== PIXELS) fail("RLE layer has incorrect decoded size");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLayer(encoded) {
|
||||||
|
let src = 0, dst = 0;
|
||||||
|
while (src < encoded.length) {
|
||||||
|
const command = encoded[src++], count = (command & 0x7f) + 1;
|
||||||
|
if (dst + count > PIXELS) fail("RLE layer expands beyond canvas size");
|
||||||
|
if (command & 0x80) {
|
||||||
|
if (src >= encoded.length) fail("truncated RLE repeat packet");
|
||||||
|
src++;
|
||||||
|
} else {
|
||||||
|
if (src + count > encoded.length) fail("truncated RLE literal packet");
|
||||||
|
src += count;
|
||||||
|
}
|
||||||
|
dst += count;
|
||||||
|
}
|
||||||
|
if (dst !== PIXELS) fail("RLE layer has incorrect decoded size");
|
||||||
|
}
|
||||||
|
const decodedLayerCache = new WeakMap();
|
||||||
|
function decodedLayer(encoded) {
|
||||||
|
let decoded = decodedLayerCache.get(encoded);
|
||||||
|
if (!decoded) { decoded = decodeLayer(encoded); decodedLayerCache.set(encoded, decoded); }
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateColor(r, g, b, a) {
|
||||||
|
if (a && r === 26 && g === 40 && b === 72) return [132, 66, 183, a];
|
||||||
|
if (a && r === 92 && g === 224 && b === 36) return [239, 68, 133, a];
|
||||||
|
return [r, g, b, a];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWebCrypto() {
|
||||||
|
if (globalThis.crypto?.subtle) return globalThis.crypto;
|
||||||
|
try { return (await import("node:crypto")).webcrypto; }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyV3(body, footer) {
|
||||||
|
const reader = new Reader(footer);
|
||||||
|
if (String.fromCharCode(...reader.take(4)) !== "SPP3") fail("invalid public-signature marker");
|
||||||
|
const identities = [];
|
||||||
|
for (let i = 0; i < 3; i++) identities.push({ publicKey: reader.take(32), deviceTag: reader.take(16) });
|
||||||
|
const signature = reader.take(64);
|
||||||
|
if (reader.at !== reader.end || !identities[2].publicKey.some(Boolean) || !identities[2].deviceTag.some(Boolean)) fail("invalid public identity footer");
|
||||||
|
const crypto = await getWebCrypto();
|
||||||
|
if (!crypto) return { status: "present-unverified", identities };
|
||||||
|
try {
|
||||||
|
const domain = new TextEncoder().encode("FlipnoteNX-SPPM-public-signature-v3\0");
|
||||||
|
const concat = new Uint8Array(domain.length + body.length + 3 * 48);
|
||||||
|
let at = 0; concat.set(domain, at); at += domain.length; concat.set(body, at); at += body.length;
|
||||||
|
for (const identity of identities) { concat.set(identity.publicKey, at); at += 32; concat.set(identity.deviceTag, at); at += 16; }
|
||||||
|
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", concat));
|
||||||
|
const key = await crypto.subtle.importKey("raw", identities[2].publicKey, { name: "Ed25519" }, false, ["verify"]);
|
||||||
|
const valid = await crypto.subtle.verify({ name: "Ed25519" }, key, signature, digest);
|
||||||
|
if (!valid) fail("public signature verification failed");
|
||||||
|
return { status: "verified", identities };
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error?.message || error).includes("public signature verification failed")) throw error;
|
||||||
|
return { status: "present-unverified", identities, verificationError: String(error?.message || error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyLegacyHmac(body, signature, key) {
|
||||||
|
if (!key) return "present-unverified";
|
||||||
|
const crypto = await getWebCrypto(); if (!crypto) return "present-unverified";
|
||||||
|
const rawKey = key instanceof Uint8Array ? key : new Uint8Array(key);
|
||||||
|
const hmacKey = await crypto.subtle.importKey("raw", rawKey, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
||||||
|
return await crypto.subtle.verify("HMAC", hmacKey, signature, body) ? "verified" : fail("legacy HMAC verification failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a complete SPPM file. Public SPP3 signatures are verified where WebCrypto supports Ed25519. */
|
||||||
|
export async function readSppm(input, { legacyHmacKey = null } = {}) {
|
||||||
|
const bytes = await inputBytes(input);
|
||||||
|
if (bytes.length < HEADER_SIZE || bytes.length > MAX_FILE_SIZE + PUBLIC_FOOTER_SIZE + 36) fail("file size outside supported limits");
|
||||||
|
if (String.fromCharCode(...bytes.subarray(0, 4)) !== "SPPM") fail("bad file magic");
|
||||||
|
|
||||||
|
let bodyEnd = bytes.length, signature = { status: "unsigned" }, publicIdentities = null;
|
||||||
|
const tail = bytes.length >= 4 ? String.fromCharCode(...bytes.subarray(-4)) : "";
|
||||||
|
if (tail === "SPP3") fail("public signature footer is truncated or malformed");
|
||||||
|
const publicAt = bytes.length - PUBLIC_FOOTER_SIZE;
|
||||||
|
if (publicAt >= HEADER_SIZE && String.fromCharCode(...bytes.subarray(publicAt, publicAt + 4)) === "SPP3") {
|
||||||
|
bodyEnd = publicAt;
|
||||||
|
const checked = await verifyV3(bytes.subarray(0, bodyEnd), bytes.subarray(publicAt));
|
||||||
|
signature = { status: checked.status, algorithm: "Ed25519", identities: checked.identities, verificationError: checked.verificationError };
|
||||||
|
publicIdentities = checked.identities;
|
||||||
|
} else if (bytes.length >= HEADER_SIZE + 36 && String.fromCharCode(...bytes.subarray(-36, -32)) === "SPPS") {
|
||||||
|
bodyEnd = bytes.length - 36;
|
||||||
|
signature = { status: await verifyLegacyHmac(bytes.subarray(0, bodyEnd), bytes.subarray(-32), legacyHmacKey), algorithm: "HMAC-SHA-256" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = bytes.subarray(0, bodyEnd);
|
||||||
|
const r = new Reader(body);
|
||||||
|
r.take(4);
|
||||||
|
const version = r.u16(), headerSize = r.u16(), totalBytes = r.u64(), expectedCrc = r.u32();
|
||||||
|
const width = r.u16(), height = r.u16(), layerCount = r.u8(), paletteCount = r.u16(), fps = r.u8(), flags = r.u8(), frameCount = r.u32(), audioCount = r.u8(), reserved = r.u16();
|
||||||
|
if ((version !== 1 && version !== 2 && version !== 3) || headerSize !== HEADER_SIZE || totalBytes !== body.length || width !== WIDTH || height !== HEIGHT || layerCount !== 4 || paletteCount !== 256 || fps < 1 || fps > 30 || frameCount < 1 || frameCount > 9999 || audioCount > 4 || (version < 3 ? reserved !== 0 : reserved > 8) || ((version === 1 ? flags & ~3 : flags & ~15) !== 0)) fail("unsupported SPPM header");
|
||||||
|
if (crc32(body.subarray(HEADER_SIZE)) !== expectedCrc) fail("payload CRC mismatch");
|
||||||
|
|
||||||
|
const paletteBytes = r.take(256 * 4), palette = new Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) palette[i] = migrateColor(...paletteBytes.subarray(i * 4, i * 4 + 4));
|
||||||
|
const metadata = {
|
||||||
|
title: r.string(256), description: r.string(4096), authorName: r.string(128), originalAuthorName: r.string(128), lastEditorName: r.string(128),
|
||||||
|
authorFsid: r.id(), originalAuthorFsid: r.id(), lastEditorFsid: r.id(), currentId: r.id(), rootId: r.id(), parentId: r.id(true),
|
||||||
|
createdUnixSeconds: r.u64(), modifiedUnixSeconds: r.u64(), coverFrame: r.u32(), width, height,
|
||||||
|
speed: version >= 3 ? reserved : 0, nominalFps: fps,
|
||||||
|
fps: version >= 3 && reserved >= 1 ? [0, 0.4985, 1, 2, 4, 5.9972, 11.986, 19.9459, 29.9275][reserved] : fps,
|
||||||
|
locked: !!(flags & 1), allowSpinoffs: !!(flags & 2), bgmFollowsSpeed: version === 1 || !!(flags & 4), loopPlayback: version === 1 ? true : !(flags & 8),
|
||||||
|
frameCount, audioTrackCount: audioCount, formatVersion: version, signature: signature.status,
|
||||||
|
};
|
||||||
|
if (metadata.coverFrame >= frameCount) fail("cover frame outside animation");
|
||||||
|
if (publicIdentities) {
|
||||||
|
metadata.identities = publicIdentities.map(identity => ({ publicKeyHex: hex(identity.publicKey), deviceTagHex: hex(identity.deviceTag) }));
|
||||||
|
const crypto = await getWebCrypto();
|
||||||
|
if (crypto) for (const [field, index] of [["authorFsid", 0], ["originalAuthorFsid", 1], ["lastEditorFsid", 2]]) {
|
||||||
|
const identity = publicIdentities[index];
|
||||||
|
if (!identity.publicKey.some(Boolean) && !identity.deviceTag.some(Boolean)) continue;
|
||||||
|
const domain = new TextEncoder().encode("FlipnoteNX-public-fsid-v2\0");
|
||||||
|
const source = new Uint8Array(domain.length + 48); source.set(domain); source.set(identity.publicKey, domain.length); source.set(identity.deviceTag, domain.length + 32);
|
||||||
|
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", source));
|
||||||
|
if (metadata[field] !== hex(digest.subarray(0, 8)).toUpperCase()) fail(`${field} does not match signed public identity`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const frames = [];
|
||||||
|
for (let fi = 0; fi < frameCount; fi++) {
|
||||||
|
const layers = [];
|
||||||
|
for (let li = 0; li < 4; li++) {
|
||||||
|
const codec = r.u8(), length = r.u32(), expected = r.u32();
|
||||||
|
if (codec !== 1 || length < 1 || length > 460800) fail("unsupported or oversized layer");
|
||||||
|
const encoded = r.take(length);
|
||||||
|
if (crc32(encoded) !== expected) fail(`layer ${li} CRC mismatch in frame ${fi}`);
|
||||||
|
validateLayer(encoded);
|
||||||
|
layers.push(encoded);
|
||||||
|
}
|
||||||
|
frames.push({ layers });
|
||||||
|
}
|
||||||
|
const audioTracks = [];
|
||||||
|
let audioSamples = 0;
|
||||||
|
for (let ti = 0; ti < audioCount; ti++) {
|
||||||
|
const index = r.u8(), channels = r.u8(), storedReference = version >= 2 ? r.u8() : 0, sampleRate = r.u32(), name = r.string(64), triggerCount = r.u16();
|
||||||
|
if (index !== ti || (channels !== 1 && channels !== 2) || sampleRate < 8000 || sampleRate > 48000 || triggerCount > frameCount || (ti === 0 && triggerCount !== 0)) fail("invalid audio metadata");
|
||||||
|
const triggers = Array.from({ length: triggerCount }, () => r.u16());
|
||||||
|
for (let i = 0; i < triggers.length; i++) if (triggers[i] >= frameCount || (i && triggers[i] <= triggers[i - 1])) fail("invalid sound-effect frame cues");
|
||||||
|
const sampleCount = r.u32(), pcmCrc = r.u32(); audioSamples += sampleCount;
|
||||||
|
if (sampleCount > 32 * 1024 * 1024 - (audioSamples - sampleCount) || sampleCount % channels !== 0) fail("audio sample count outside supported limits");
|
||||||
|
const pcmBytes = r.take(sampleCount * 2);
|
||||||
|
if (crc32(pcmBytes) !== pcmCrc) fail("audio PCM CRC mismatch");
|
||||||
|
const pcm = new Int16Array(sampleCount);
|
||||||
|
const sampleView = new DataView(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength);
|
||||||
|
for (let i = 0; i < sampleCount; i++) pcm[i] = sampleView.getInt16(i * 2, true);
|
||||||
|
const referenceSpeed = version >= 3 && reserved !== 0 && ti === 0 ? storedReference : 0;
|
||||||
|
const referenceFps = version >= 3 && reserved !== 0 ? 0 : ti === 0 && version === 1 ? fps : storedReference;
|
||||||
|
if (version >= 3 && reserved !== 0 && (ti === 0 ? storedReference > 8 : storedReference !== 0)) fail("invalid audio speed metadata");
|
||||||
|
audioTracks.push({ index, name, channels, referenceFps, referenceSpeed, sampleRate, frameTriggers: triggers, pcm });
|
||||||
|
}
|
||||||
|
if (r.at !== body.length) fail("unexpected trailing bytes");
|
||||||
|
return { width, height, palette, metadata, frames, audioTracks, signature };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hex(bytes) { return [...bytes].map(value => value.toString(16).padStart(2, "0")).join(""); }
|
||||||
|
|
||||||
|
/** Read only the public metadata fields from an SPPM file. */
|
||||||
|
export async function getSppmMetadata(input, options) {
|
||||||
|
return (await readSppm(input, options)).metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Composite a frame to RGBA bytes, with layer A at the front and D at the back. */
|
||||||
|
export function renderFrame(note, frameIndex, { background = [255, 255, 255, 255] } = {}) {
|
||||||
|
if (!note?.frames?.length || frameIndex < 0 || frameIndex >= note.frames.length) throw new RangeError("frameIndex outside note");
|
||||||
|
const out = new Uint8ClampedArray(PIXELS * 4), frame = note.frames[frameIndex], layers = frame.layers.map(decodedLayer), [br, bg, bb, ba] = background;
|
||||||
|
for (let p = 0; p < PIXELS; p++) {
|
||||||
|
let r = br, g = bg, b = bb, a = ba;
|
||||||
|
for (let layer = 3; layer >= 0; layer--) {
|
||||||
|
const colorIndex = layers[layer][p]; if (colorIndex === 0) continue;
|
||||||
|
const [sr, sg, sb, sa] = note.palette[colorIndex]; if (!sa) continue;
|
||||||
|
const sourceWeight = sa * 255, behindWeight = a * (255 - sa), total = sourceWeight + behindWeight;
|
||||||
|
if (!total) { r = g = b = a = 0; continue; }
|
||||||
|
r = Math.floor((sr * sourceWeight + r * behindWeight + total / 2) / total);
|
||||||
|
g = Math.floor((sg * sourceWeight + g * behindWeight + total / 2) / total);
|
||||||
|
b = Math.floor((sb * sourceWeight + b * behindWeight + total / 2) / total);
|
||||||
|
a = Math.floor((total + 127) / 255);
|
||||||
|
}
|
||||||
|
const at = p * 4; out[at] = r; out[at + 1] = g; out[at + 2] = b; out[at + 3] = a;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get an RGBA thumbnail for the note's designated cover frame. */
|
||||||
|
export async function getSppmThumbnail(input, { width = 320, height = 180, background = [255, 255, 255, 255], ...readOptions } = {}) {
|
||||||
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || width > 4096 || height > 4096) throw new RangeError("thumbnail dimensions must be 1..4096");
|
||||||
|
const note = await readSppm(input, readOptions), source = renderFrame(note, note.metadata.coverFrame, { background });
|
||||||
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
|
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
||||||
|
const sx = Math.min(WIDTH - 1, Math.floor(x * WIDTH / width)), sy = Math.min(HEIGHT - 1, Math.floor(y * HEIGHT / height));
|
||||||
|
data.set(source.subarray((sy * WIDTH + sx) * 4, (sy * WIDTH + sx) * 4 + 4), (y * width + x) * 4);
|
||||||
|
}
|
||||||
|
return { width, height, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw the cover thumbnail into a browser canvas (keeps nearest-pixel sampling). */
|
||||||
|
export async function drawSppmThumbnail(canvas, input, options = {}) {
|
||||||
|
const thumbnail = await getSppmThumbnail(input, { width: canvas.width || 320, height: canvas.height || 180, ...options });
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.putImageData(new ImageData(thumbnail.data, thumbnail.width, thumbnail.height), 0, 0);
|
||||||
|
return thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canvas animation playback, including SPPM BGM and frame-cued sound effects. */
|
||||||
|
export class SppmPlayer {
|
||||||
|
constructor(canvas, note, { audio = true, context = null } = {}) {
|
||||||
|
if (!canvas?.getContext || !note?.frames?.length) throw new TypeError("SppmPlayer requires a canvas and a parsed note");
|
||||||
|
this.canvas = canvas; this.note = note; this.audioEnabled = audio; this.audioContext = context;
|
||||||
|
this.frame = 0; this.playing = false; this.loop = note.metadata.loopPlayback; this.raf = 0; this.startedAt = 0; this.cycle = 0; this.sources = [];
|
||||||
|
canvas.width = note.width; canvas.height = note.height;
|
||||||
|
this.context2d = canvas.getContext("2d", { alpha: false });
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
const rgba = renderFrame(this.note, this.frame);
|
||||||
|
this.context2d.putImageData(new ImageData(rgba, this.note.width, this.note.height), 0, 0);
|
||||||
|
this.onframechange?.(this.frame);
|
||||||
|
}
|
||||||
|
async play({ fromStart = true } = {}) {
|
||||||
|
if (fromStart) this.frame = 0;
|
||||||
|
if (this.playing && !fromStart) return;
|
||||||
|
this.#stopAudio(); this.render();
|
||||||
|
this.playing = true; this.cycle = 0; this.startedAt = performance.now();
|
||||||
|
if (this.audioEnabled) await this.#scheduleAudio();
|
||||||
|
const tick = now => {
|
||||||
|
if (!this.playing) return;
|
||||||
|
const cycleMs = this.note.frames.length * 1000 / this.note.metadata.fps;
|
||||||
|
const elapsed = now - this.startedAt;
|
||||||
|
if (elapsed >= cycleMs) {
|
||||||
|
if (this.loop) {
|
||||||
|
this.cycle++; this.startedAt += cycleMs; this.#stopAudio();
|
||||||
|
if (this.audioEnabled) this.#scheduleAudio().catch(error => this.onerror?.(error));
|
||||||
|
} else {
|
||||||
|
this.frame = this.note.frames.length - 1; this.render(); this.playing = false; this.#stopAudio(); this.onended?.(); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nextFrame = Math.min(this.note.frames.length - 1, Math.floor((now - this.startedAt) * this.note.metadata.fps / 1000));
|
||||||
|
if (nextFrame !== this.frame) { this.frame = nextFrame; this.render(); }
|
||||||
|
this.raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
this.raf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
pause() {
|
||||||
|
if (!this.playing) return;
|
||||||
|
this.playing = false; cancelAnimationFrame(this.raf);
|
||||||
|
this.frame = Math.min(this.note.frames.length - 1, Math.floor((performance.now() - this.startedAt) * this.note.metadata.fps / 1000));
|
||||||
|
this.#stopAudio(); this.render(); this.onpause?.(this.frame);
|
||||||
|
}
|
||||||
|
stop() { this.playing = false; cancelAnimationFrame(this.raf); this.frame = 0; this.#stopAudio(); this.render(); }
|
||||||
|
seek(frame) {
|
||||||
|
if (!Number.isInteger(frame) || frame < 0 || frame >= this.note.frames.length) throw new RangeError("frame outside note");
|
||||||
|
this.frame = frame; this.render();
|
||||||
|
if (this.playing) { this.startedAt = performance.now() - frame * 1000 / this.note.metadata.fps; this.#stopAudio(); if (this.audioEnabled) this.#scheduleAudio().catch(error => this.onerror?.(error)); }
|
||||||
|
}
|
||||||
|
destroy() { this.stop(); if (this.audioContext && !this.externalAudioContext) this.audioContext.close?.(); }
|
||||||
|
async #scheduleAudio() {
|
||||||
|
const AudioContextCtor = globalThis.AudioContext || globalThis.webkitAudioContext;
|
||||||
|
if (!this.audioContext) { if (!AudioContextCtor) return; this.audioContext = new AudioContextCtor(); }
|
||||||
|
await this.audioContext.resume?.();
|
||||||
|
const start = this.audioContext.currentTime + 0.04;
|
||||||
|
const fps = this.note.metadata.fps;
|
||||||
|
for (const track of this.note.audioTracks) {
|
||||||
|
if (!track.pcm.length) continue;
|
||||||
|
const buffer = this.audioContext.createBuffer(track.channels, track.pcm.length / track.channels, track.sampleRate);
|
||||||
|
for (let ch = 0; ch < track.channels; ch++) {
|
||||||
|
const channel = buffer.getChannelData(ch);
|
||||||
|
for (let i = 0; i < channel.length; i++) channel[i] = track.pcm[i * track.channels + ch] / 32768;
|
||||||
|
}
|
||||||
|
if (track.index === 0) {
|
||||||
|
const source = this.audioContext.createBufferSource(); source.buffer = buffer;
|
||||||
|
if (this.note.metadata.bgmFollowsSpeed) source.playbackRate.value = fps / (track.referenceSpeed ? [0, 0.4985, 1, 2, 4, 5.9972, 11.986, 19.9459, 29.9275][track.referenceSpeed] : (track.referenceFps || fps));
|
||||||
|
source.loop = !!this.loop; source.connect(this.audioContext.destination); source.start(start); this.sources.push(source);
|
||||||
|
} else for (const cue of track.frameTriggers) {
|
||||||
|
const source = this.audioContext.createBufferSource(); source.buffer = buffer; source.connect(this.audioContext.destination);
|
||||||
|
source.start(start + cue / fps); this.sources.push(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#stopAudio() { for (const source of this.sources) { try { source.stop(); } catch {} try { source.disconnect(); } catch {} } this.sources = []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SPPM = Object.freeze({ width: WIDTH, height: HEIGHT, layerCount: 4, paletteSize: 256 });
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "@switchnote/switchnote-tools",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Read, verify, play, thumbnail, and convert Switchnote SPPM files",
|
||||||
|
"type": "module",
|
||||||
|
"exports": "./index.js",
|
||||||
|
"scripts": { "test": "node ./test/sppm.test.js" },
|
||||||
|
"engines": { "node": ">=20" },
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { getSppmMetadata, getSppmThumbnail, readSppm, renderFrame } from "../index.js";
|
||||||
|
|
||||||
|
function crc32(bytes) {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const b of bytes) { crc ^= b; for (let i = 0; i < 8; i++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); }
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
function fixture(version = 2, speed = 0) {
|
||||||
|
const parts = [];
|
||||||
|
const u8 = n => parts.push(Buffer.from([n & 255]));
|
||||||
|
const u16 = n => { const b = Buffer.alloc(2); b.writeUInt16LE(n); parts.push(b); };
|
||||||
|
const u32 = n => { const b = Buffer.alloc(4); b.writeUInt32LE(n); parts.push(b); };
|
||||||
|
const u64 = n => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); parts.push(b); };
|
||||||
|
const str = s => { const b = Buffer.from(s); u16(b.length); parts.push(b); };
|
||||||
|
parts.push(Buffer.from("SPPM")); u16(version); u16(36); u64(0); u32(0); u16(640); u16(360); u8(4); u16(256); u8(12); u8(2); u32(1); u8(0); u16(speed);
|
||||||
|
const palette = Buffer.alloc(1024); palette.set([255, 0, 0, 255], 4); parts.push(palette);
|
||||||
|
["JS fixture", "", "Tester", "Tester", "Tester"].forEach(str);
|
||||||
|
["1111111111111111", "1111111111111111", "1111111111111111", "2222222222222222", "2222222222222222", "0000000000000000"].forEach(id => parts.push(Buffer.from(id)));
|
||||||
|
u64(100); u64(100); u32(0);
|
||||||
|
const zeroRun = Buffer.alloc(3600); for (let i = 0; i < zeroRun.length; i += 2) { zeroRun[i] = 255; zeroRun[i + 1] = 0; }
|
||||||
|
const firstLayer = Buffer.concat([Buffer.from([0, 1]), zeroRun.subarray(0, 3598), Buffer.from([254, 0])]); // one literal plus 230399 zeroes
|
||||||
|
for (let layer = 0; layer < 4; layer++) {
|
||||||
|
const encoded = layer === 0 ? firstLayer : zeroRun;
|
||||||
|
u8(1); u32(encoded.length); u32(crc32(encoded)); parts.push(encoded);
|
||||||
|
}
|
||||||
|
let body = Buffer.concat(parts); body.writeBigUInt64LE(BigInt(body.length), 8); body.writeUInt32LE(crc32(body.subarray(36)), 16);
|
||||||
|
return new Uint8Array(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("reads metadata, validates RLE and renders a white-backed cover thumbnail", async () => {
|
||||||
|
const bytes = fixture();
|
||||||
|
const note = await readSppm(bytes);
|
||||||
|
assert.equal((await getSppmMetadata(bytes)).title, "JS fixture");
|
||||||
|
assert.equal(note.frames.length, 1);
|
||||||
|
const rgba = renderFrame(note, 0);
|
||||||
|
assert.deepEqual([...rgba.subarray(0, 4)], [255, 0, 0, 255]);
|
||||||
|
const thumb = await getSppmThumbnail(bytes, { width: 2, height: 1 });
|
||||||
|
assert.deepEqual([...thumb.data.subarray(0, 4)], [255, 0, 0, 255]);
|
||||||
|
assert.deepEqual([...thumb.data.subarray(4, 8)], [255, 255, 255, 255]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects damaged payload CRC", async () => {
|
||||||
|
const bytes = fixture(); bytes[80] ^= 1;
|
||||||
|
await assert.rejects(readSppm(bytes), /CRC/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads DSi speed indices using the measured frame rates", async () => {
|
||||||
|
const bytes = fixture(3, 1);
|
||||||
|
const note = await readSppm(bytes);
|
||||||
|
assert.equal(note.metadata.speed, 1);
|
||||||
|
assert.equal(note.metadata.fps, 0.4985);
|
||||||
|
assert.equal(note.metadata.nominalFps, 12);
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Pillow>=10.0
|
||||||
|
numpy>=1.24
|
||||||
|
# Optional: install this to verify current SPP3 Ed25519 signatures.
|
||||||
|
# cryptography>=41.0
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
"""Read, preview, and convert Switchnote Studio SPPM files."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import array
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import wave
|
||||||
|
import zlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import BinaryIO, Optional, Union
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
WIDTH, HEIGHT = 640, 360
|
||||||
|
PIXELS = WIDTH * HEIGHT
|
||||||
|
HEADER_SIZE = 36
|
||||||
|
MAX_FILE_SIZE = 512 * 1024 * 1024
|
||||||
|
MAX_AUDIO_SAMPLES = 32 * 1024 * 1024
|
||||||
|
PUBLIC_FOOTER_SIZE = 4 + 3 * 48 + 64
|
||||||
|
Input = Union[str, os.PathLike, bytes, bytearray, memoryview]
|
||||||
|
|
||||||
|
|
||||||
|
class SppmError(ValueError):
|
||||||
|
"""Malformed, unsupported, or damaged SPPM input."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioTrack:
|
||||||
|
index: int
|
||||||
|
channels: int
|
||||||
|
reference_fps: int
|
||||||
|
sample_rate: int
|
||||||
|
name: str
|
||||||
|
frame_triggers: list[int]
|
||||||
|
pcm: array.array
|
||||||
|
reference_speed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SppmNote:
|
||||||
|
metadata: dict
|
||||||
|
palette: list[tuple[int, int, int, int]]
|
||||||
|
# Each frame stores four validated RLE byte strings, A through D.
|
||||||
|
frames: list[tuple[memoryview, ...]]
|
||||||
|
audio_tracks: list[AudioTrack]
|
||||||
|
signature: dict
|
||||||
|
|
||||||
|
|
||||||
|
def _read_input(source: Input) -> bytes:
|
||||||
|
if isinstance(source, (bytes, bytearray, memoryview)):
|
||||||
|
data = bytes(source)
|
||||||
|
else:
|
||||||
|
data = Path(source).read_bytes()
|
||||||
|
if len(data) < HEADER_SIZE or len(data) > MAX_FILE_SIZE + PUBLIC_FOOTER_SIZE + 36:
|
||||||
|
raise SppmError("file size outside supported limits")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class _Reader:
|
||||||
|
def __init__(self, data: bytes | memoryview, end: Optional[int] = None):
|
||||||
|
self.data = memoryview(data)
|
||||||
|
self.end = len(self.data) if end is None else end
|
||||||
|
self.at = 0
|
||||||
|
|
||||||
|
def take(self, size: int) -> memoryview:
|
||||||
|
if size < 0 or size > self.end - self.at:
|
||||||
|
raise SppmError("truncated payload")
|
||||||
|
start = self.at
|
||||||
|
self.at += size
|
||||||
|
return self.data[start:self.at]
|
||||||
|
|
||||||
|
def u8(self) -> int:
|
||||||
|
return self.take(1)[0]
|
||||||
|
|
||||||
|
def u16(self) -> int:
|
||||||
|
return struct.unpack("<H", self.take(2))[0]
|
||||||
|
|
||||||
|
def u32(self) -> int:
|
||||||
|
return struct.unpack("<I", self.take(4))[0]
|
||||||
|
|
||||||
|
def u64(self) -> int:
|
||||||
|
return struct.unpack("<Q", self.take(8))[0]
|
||||||
|
|
||||||
|
def string(self, maximum: int) -> str:
|
||||||
|
size = self.u16()
|
||||||
|
if size > maximum:
|
||||||
|
raise SppmError("metadata string exceeds limit")
|
||||||
|
raw = bytes(self.take(size))
|
||||||
|
if b"\0" in raw:
|
||||||
|
raise SppmError("metadata contains NUL")
|
||||||
|
try:
|
||||||
|
return raw.decode("utf-8", "strict")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise SppmError("metadata is not valid UTF-8") from exc
|
||||||
|
|
||||||
|
def id(self, optional: bool = False) -> str:
|
||||||
|
value = bytes(self.take(16)).decode("ascii", "strict")
|
||||||
|
if optional and value == "0" * 16:
|
||||||
|
return ""
|
||||||
|
if len(value) != 16 or any(c not in "0123456789abcdefABCDEF" for c in value) or value == "0" * 16:
|
||||||
|
raise SppmError("invalid lineage ID")
|
||||||
|
return value.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_rle(encoded: memoryview) -> None:
|
||||||
|
src = 0
|
||||||
|
output = 0
|
||||||
|
while src < len(encoded):
|
||||||
|
command = encoded[src]
|
||||||
|
src += 1
|
||||||
|
count = (command & 0x7F) + 1
|
||||||
|
if output + count > PIXELS:
|
||||||
|
raise SppmError("RLE layer expands beyond canvas size")
|
||||||
|
if command & 0x80:
|
||||||
|
if src >= len(encoded):
|
||||||
|
raise SppmError("truncated RLE repeat packet")
|
||||||
|
src += 1
|
||||||
|
else:
|
||||||
|
if src + count > len(encoded):
|
||||||
|
raise SppmError("truncated RLE literal packet")
|
||||||
|
src += count
|
||||||
|
output += count
|
||||||
|
if output != PIXELS:
|
||||||
|
raise SppmError("RLE layer has incorrect decoded size")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_layer(encoded: memoryview) -> bytes:
|
||||||
|
out = bytearray(PIXELS)
|
||||||
|
src = 0
|
||||||
|
dst = 0
|
||||||
|
while src < len(encoded):
|
||||||
|
command = encoded[src]
|
||||||
|
src += 1
|
||||||
|
count = (command & 0x7F) + 1
|
||||||
|
if command & 0x80:
|
||||||
|
value = encoded[src]
|
||||||
|
src += 1
|
||||||
|
out[dst:dst + count] = bytes((value,)) * count
|
||||||
|
else:
|
||||||
|
out[dst:dst + count] = encoded[src:src + count]
|
||||||
|
src += count
|
||||||
|
dst += count
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_fsid(key: bytes, tag: bytes) -> str:
|
||||||
|
if not any(key) or not any(tag):
|
||||||
|
return ""
|
||||||
|
digest = hashlib.sha256(b"FlipnoteNX-public-fsid-v2\0" + key + tag).digest()
|
||||||
|
return digest[:8].hex().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_public_signature(body: bytes, footer: memoryview) -> tuple[dict, list[tuple[bytes, bytes]]]:
|
||||||
|
fr = _Reader(footer)
|
||||||
|
if bytes(fr.take(4)) != b"SPP3":
|
||||||
|
raise SppmError("invalid public-signature marker")
|
||||||
|
identities = [(bytes(fr.take(32)), bytes(fr.take(16))) for _ in range(3)]
|
||||||
|
signature = bytes(fr.take(64))
|
||||||
|
if fr.at != fr.end or not any(identities[2][0]) or not any(identities[2][1]):
|
||||||
|
raise SppmError("invalid public identity footer")
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
b"FlipnoteNX-SPPM-public-signature-v3\0" + body + b"".join(k + tag for k, tag in identities)
|
||||||
|
).digest()
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
Ed25519PublicKey.from_public_bytes(identities[2][0]).verify(signature, digest)
|
||||||
|
status = "verified"
|
||||||
|
except ImportError:
|
||||||
|
status = "present-unverified"
|
||||||
|
except Exception as exc:
|
||||||
|
raise SppmError("public signature verification failed") from exc
|
||||||
|
return {"status": status, "algorithm": "Ed25519"}, identities
|
||||||
|
|
||||||
|
|
||||||
|
def read_sppm(source: Input, *, legacy_hmac_key: Optional[bytes] = None) -> SppmNote:
|
||||||
|
"""Parse and structurally validate an SPPM note. V3 signature verification uses optional `cryptography`."""
|
||||||
|
data = _read_input(source)
|
||||||
|
if data[:4] != b"SPPM":
|
||||||
|
raise SppmError("bad file magic")
|
||||||
|
body_end = len(data)
|
||||||
|
signature = {"status": "unsigned"}
|
||||||
|
identities = None
|
||||||
|
public_at = len(data) - PUBLIC_FOOTER_SIZE
|
||||||
|
if public_at >= HEADER_SIZE and data[public_at:public_at + 4] == b"SPP3":
|
||||||
|
body_end = public_at
|
||||||
|
signature, identities = _verify_public_signature(data[:body_end], memoryview(data)[public_at:])
|
||||||
|
elif len(data) >= HEADER_SIZE + 36 and data[-36:-32] == b"SPPS":
|
||||||
|
body_end = len(data) - 36
|
||||||
|
signature = {"status": "present-unverified", "algorithm": "HMAC-SHA-256"}
|
||||||
|
if legacy_hmac_key is not None:
|
||||||
|
valid = hmac.compare_digest(hmac.new(legacy_hmac_key, data[:body_end], hashlib.sha256).digest(), data[-32:])
|
||||||
|
if not valid:
|
||||||
|
raise SppmError("legacy HMAC verification failed")
|
||||||
|
signature["status"] = "verified"
|
||||||
|
|
||||||
|
body = memoryview(data)[:body_end]
|
||||||
|
r = _Reader(body)
|
||||||
|
r.take(4)
|
||||||
|
version, header_size = r.u16(), r.u16()
|
||||||
|
total_size, expected_crc = r.u64(), r.u32()
|
||||||
|
width, height, layer_count, palette_count, fps, flags = r.u16(), r.u16(), r.u8(), r.u16(), r.u8(), r.u8()
|
||||||
|
frame_count, audio_count, reserved = r.u32(), r.u8(), r.u16()
|
||||||
|
if (version not in (1, 2, 3) or header_size != HEADER_SIZE or total_size != len(body) or
|
||||||
|
width != WIDTH or height != HEIGHT or layer_count != 4 or palette_count != 256 or
|
||||||
|
not 1 <= fps <= 30 or not 1 <= frame_count <= 9999 or audio_count > 4 or (reserved != 0 if version < 3 else reserved > 8) or
|
||||||
|
(flags & ~(3 if version == 1 else 15))):
|
||||||
|
raise SppmError("unsupported SPPM header")
|
||||||
|
if zlib.crc32(body[HEADER_SIZE:]) & 0xFFFFFFFF != expected_crc:
|
||||||
|
raise SppmError("payload CRC mismatch")
|
||||||
|
|
||||||
|
palbytes = r.take(256 * 4)
|
||||||
|
palette = []
|
||||||
|
for i in range(256):
|
||||||
|
rgba = tuple(palbytes[i * 4:i * 4 + 4])
|
||||||
|
rr, gg, bb, aa = rgba
|
||||||
|
if aa and (rr, gg, bb) == (26, 40, 72): rgba = (132, 66, 183, aa)
|
||||||
|
elif aa and (rr, gg, bb) == (92, 224, 36): rgba = (239, 68, 133, aa)
|
||||||
|
palette.append(rgba)
|
||||||
|
meta = {
|
||||||
|
"title": r.string(256), "description": r.string(4096), "authorName": r.string(128),
|
||||||
|
"originalAuthorName": r.string(128), "lastEditorName": r.string(128),
|
||||||
|
"authorFsid": r.id(), "originalAuthorFsid": r.id(), "lastEditorFsid": r.id(),
|
||||||
|
"currentId": r.id(), "rootId": r.id(), "parentId": r.id(True),
|
||||||
|
"createdUnixSeconds": r.u64(), "modifiedUnixSeconds": r.u64(), "coverFrame": r.u32(),
|
||||||
|
"width": width, "height": height,
|
||||||
|
"speed": reserved if version >= 3 else 0, "nominalFps": fps,
|
||||||
|
"fps": (0.4985, 1.0, 2.0, 4.0, 5.9972, 11.986, 19.9459, 29.9275)[reserved - 1] if version >= 3 and reserved else fps,
|
||||||
|
"locked": bool(flags & 1),
|
||||||
|
"allowSpinoffs": bool(flags & 2), "bgmFollowsSpeed": version == 1 or bool(flags & 4),
|
||||||
|
"loopPlayback": True if version == 1 else not bool(flags & 8),
|
||||||
|
"frameCount": frame_count, "audioTrackCount": audio_count, "formatVersion": version,
|
||||||
|
"signature": signature["status"],
|
||||||
|
}
|
||||||
|
if meta["coverFrame"] >= frame_count:
|
||||||
|
raise SppmError("cover frame outside animation")
|
||||||
|
if identities:
|
||||||
|
meta["identities"] = [{"publicKeyHex": key.hex(), "deviceTagHex": tag.hex()} for key, tag in identities]
|
||||||
|
for field, identity_index in (("authorFsid", 0), ("originalAuthorFsid", 1), ("lastEditorFsid", 2)):
|
||||||
|
key, tag = identities[identity_index]
|
||||||
|
derived = _identity_fsid(key, tag)
|
||||||
|
if derived and meta[field] != derived:
|
||||||
|
raise SppmError(f"{field} does not match signed public identity")
|
||||||
|
|
||||||
|
frames = []
|
||||||
|
for fi in range(frame_count):
|
||||||
|
layers = []
|
||||||
|
for li in range(4):
|
||||||
|
codec, length, crc = r.u8(), r.u32(), r.u32()
|
||||||
|
if codec != 1 or length < 1 or length > 460800:
|
||||||
|
raise SppmError("unsupported or oversized layer")
|
||||||
|
encoded = r.take(length)
|
||||||
|
if zlib.crc32(encoded) & 0xFFFFFFFF != crc:
|
||||||
|
raise SppmError(f"layer {li} CRC mismatch in frame {fi}")
|
||||||
|
_validate_rle(encoded)
|
||||||
|
layers.append(encoded)
|
||||||
|
frames.append(tuple(layers))
|
||||||
|
|
||||||
|
tracks = []
|
||||||
|
total_samples = 0
|
||||||
|
for ti in range(audio_count):
|
||||||
|
index, channels = r.u8(), r.u8()
|
||||||
|
reference_fps = r.u8() if version >= 2 else 0
|
||||||
|
sample_rate, name, cue_count = r.u32(), r.string(64), r.u16()
|
||||||
|
if index != ti or channels not in (1, 2) or not 8000 <= sample_rate <= 48000 or cue_count > frame_count or (ti == 0 and cue_count):
|
||||||
|
raise SppmError("invalid audio metadata")
|
||||||
|
cues = [r.u16() for _ in range(cue_count)]
|
||||||
|
if any(c >= frame_count for c in cues) or any(a >= b for a, b in zip(cues, cues[1:])):
|
||||||
|
raise SppmError("invalid sound-effect frame cues")
|
||||||
|
sample_count, pcm_crc = r.u32(), r.u32()
|
||||||
|
total_samples += sample_count
|
||||||
|
if total_samples > MAX_AUDIO_SAMPLES or sample_count % channels:
|
||||||
|
raise SppmError("audio sample count outside supported limits")
|
||||||
|
raw_pcm = bytes(r.take(sample_count * 2))
|
||||||
|
if zlib.crc32(raw_pcm) & 0xFFFFFFFF != pcm_crc:
|
||||||
|
raise SppmError("audio PCM CRC mismatch")
|
||||||
|
pcm = array.array("h")
|
||||||
|
pcm.frombytes(raw_pcm)
|
||||||
|
if struct.pack("=H", 1) != b"\x01\x00":
|
||||||
|
pcm.byteswap()
|
||||||
|
reference_speed = reference_fps if version >= 3 and reserved != 0 and ti == 0 else 0
|
||||||
|
if version >= 3 and reserved != 0 and (reference_speed > 8 or (ti > 0 and reference_fps != 0)):
|
||||||
|
raise SppmError("invalid audio speed metadata")
|
||||||
|
legacy_reference_fps = (fps if ti == 0 else reference_fps) if version < 3 or reserved == 0 else 0
|
||||||
|
tracks.append(AudioTrack(index, channels, legacy_reference_fps,
|
||||||
|
sample_rate, name, cues, pcm, reference_speed))
|
||||||
|
if r.at != body_end:
|
||||||
|
raise SppmError("unexpected trailing bytes")
|
||||||
|
return SppmNote(meta, palette, frames, tracks, signature)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sppm_metadata(source: Input, **kwargs) -> dict:
|
||||||
|
"""Return the note's metadata dictionary (including signature status)."""
|
||||||
|
return read_sppm(source, **kwargs).metadata
|
||||||
|
|
||||||
|
|
||||||
|
def render_frame(note: SppmNote, frame_index: int, *, background=(255, 255, 255, 255)) -> Image.Image:
|
||||||
|
"""Composite one animation frame to a 640x360 RGBA Pillow image."""
|
||||||
|
if not 0 <= frame_index < len(note.frames):
|
||||||
|
raise IndexError("frame index outside note")
|
||||||
|
layers = [_decode_layer(layer) for layer in note.frames[frame_index]]
|
||||||
|
rgba = bytearray(PIXELS * 4)
|
||||||
|
br, bg, bb, ba = background
|
||||||
|
for p in range(PIXELS):
|
||||||
|
rr, gg, bl, alpha = br, bg, bb, ba
|
||||||
|
# Stored A-D from front to back: compose from D up through A.
|
||||||
|
for layer_index in (3, 2, 1, 0):
|
||||||
|
ink = layers[layer_index][p]
|
||||||
|
if ink == 0:
|
||||||
|
continue
|
||||||
|
sr, sg, sb, sa = note.palette[ink]
|
||||||
|
if sa == 0:
|
||||||
|
continue
|
||||||
|
source_weight = sa * 255
|
||||||
|
behind_weight = alpha * (255 - sa)
|
||||||
|
total = source_weight + behind_weight
|
||||||
|
out_a = (total + 127) // 255
|
||||||
|
if out_a:
|
||||||
|
rr = (sr * source_weight + rr * behind_weight + total // 2) // total
|
||||||
|
gg = (sg * source_weight + gg * behind_weight + total // 2) // total
|
||||||
|
bl = (sb * source_weight + bl * behind_weight + total // 2) // total
|
||||||
|
alpha = out_a
|
||||||
|
at = p * 4
|
||||||
|
rgba[at:at + 4] = bytes((rr, gg, bl, alpha))
|
||||||
|
return Image.frombytes("RGBA", (WIDTH, HEIGHT), bytes(rgba))
|
||||||
|
|
||||||
|
|
||||||
|
def get_sppm_thumbnail(source: Input, output: Optional[Union[str, os.PathLike]] = None,
|
||||||
|
*, size=(320, 180), background=(255, 255, 255, 255), **kwargs) -> Image.Image:
|
||||||
|
"""Render the designated cover frame as a solid-background thumbnail."""
|
||||||
|
if len(size) != 2 or not all(isinstance(v, int) and 1 <= v <= 4096 for v in size):
|
||||||
|
raise ValueError("thumbnail size values must be in 1..4096")
|
||||||
|
note = read_sppm(source, **kwargs)
|
||||||
|
image = render_frame(note, note.metadata["coverFrame"], background=background)
|
||||||
|
if size != (WIDTH, HEIGHT):
|
||||||
|
image = image.resize(size, Image.Resampling.NEAREST)
|
||||||
|
if output is not None:
|
||||||
|
image.save(output, format="PNG")
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def _source_stereo(track: AudioTrack) -> np.ndarray:
|
||||||
|
source = np.frombuffer(track.pcm, dtype=np.int16).astype(np.float32).reshape(-1, track.channels) / 32768.0
|
||||||
|
if track.channels == 1:
|
||||||
|
return np.repeat(source, 2, axis=1)
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def _mix_wav(note: SppmNote, wav_path: str, sample_rate: int = 48000, chunk_seconds: int = 1) -> None:
|
||||||
|
total_frames = round(len(note.frames) * sample_rate / note.metadata["fps"])
|
||||||
|
fps = note.metadata["fps"]
|
||||||
|
sources = [(track, _source_stereo(track)) for track in note.audio_tracks if len(track.pcm)]
|
||||||
|
with wave.open(wav_path, "wb") as output:
|
||||||
|
output.setnchannels(2)
|
||||||
|
output.setsampwidth(2)
|
||||||
|
output.setframerate(sample_rate)
|
||||||
|
for base in range(0, total_frames, sample_rate * chunk_seconds):
|
||||||
|
count = min(sample_rate * chunk_seconds, total_frames - base)
|
||||||
|
mix = np.zeros((count, 2), dtype=np.float32)
|
||||||
|
times = (base + np.arange(count, dtype=np.float64)) / sample_rate
|
||||||
|
for track, source in sources:
|
||||||
|
if track.index == 0:
|
||||||
|
reference_fps = ((0.4985, 1.0, 2.0, 4.0, 5.9972, 11.986, 19.9459, 29.9275)[track.reference_speed - 1]
|
||||||
|
if track.reference_speed else (track.reference_fps or fps))
|
||||||
|
rate = track.sample_rate * (fps / reference_fps if note.metadata["bgmFollowsSpeed"] else 1.0)
|
||||||
|
positions = times * rate
|
||||||
|
valid = np.ones(count, dtype=bool) if note.metadata["loopPlayback"] else positions < len(source)
|
||||||
|
if note.metadata["loopPlayback"]:
|
||||||
|
positions = np.mod(positions, len(source))
|
||||||
|
indexes = np.minimum(positions.astype(np.int64), max(0, len(source) - 1))
|
||||||
|
next_indexes = np.minimum(indexes + 1, len(source) - 1)
|
||||||
|
fraction = (positions - indexes).astype(np.float32)[:, None]
|
||||||
|
sampled = source[indexes] * (1.0 - fraction) + source[next_indexes] * fraction
|
||||||
|
mix[valid] += sampled[valid]
|
||||||
|
else:
|
||||||
|
source_len = len(source)
|
||||||
|
for cue in track.frame_triggers:
|
||||||
|
start = round(cue * sample_rate / fps)
|
||||||
|
duration = int(np.ceil(source_len * sample_rate / track.sample_rate))
|
||||||
|
out_start = max(base, start)
|
||||||
|
out_end = min(base + count, start + duration)
|
||||||
|
if out_end <= out_start:
|
||||||
|
continue
|
||||||
|
dst = np.arange(out_start, out_end, dtype=np.float64)
|
||||||
|
positions = (dst - start) * track.sample_rate / sample_rate
|
||||||
|
indexes = np.minimum(positions.astype(np.int64), source_len - 1)
|
||||||
|
next_indexes = np.minimum(indexes + 1, source_len - 1)
|
||||||
|
fraction = (positions - indexes).astype(np.float32)[:, None]
|
||||||
|
sampled = source[indexes] * (1.0 - fraction) + source[next_indexes] * fraction
|
||||||
|
mix[out_start - base:out_end - base] += sampled
|
||||||
|
pcm = np.clip(mix, -1.0, 1.0)
|
||||||
|
output.writeframesraw((pcm * 32767.0).astype("<i2").tobytes())
|
||||||
|
|
||||||
|
|
||||||
|
def sppm_to_mp4(source: Input, output: Union[str, os.PathLike], *, ffmpeg: str = "ffmpeg",
|
||||||
|
include_audio: bool = True, video_crf: int = 18, preset: str = "medium",
|
||||||
|
legacy_hmac_key: Optional[bytes] = None) -> Path:
|
||||||
|
"""Convert a note to H.264/AAC MP4. Requires Pillow, NumPy, and ffmpeg on PATH."""
|
||||||
|
note = read_sppm(source, legacy_hmac_key=legacy_hmac_key)
|
||||||
|
ffmpeg_path = shutil.which(ffmpeg) if not os.path.isfile(ffmpeg) else ffmpeg
|
||||||
|
if not ffmpeg_path:
|
||||||
|
raise FileNotFoundError("ffmpeg was not found; install ffmpeg or pass ffmpeg=... ")
|
||||||
|
target = Path(output)
|
||||||
|
if target.suffix.lower() != ".mp4":
|
||||||
|
raise ValueError("output filename must end in .mp4")
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="sppm-mp4-") as temp_dir:
|
||||||
|
wav_path = os.path.join(temp_dir, "audio.wav")
|
||||||
|
has_audio = include_audio and any(track.pcm for track in note.audio_tracks)
|
||||||
|
if has_audio:
|
||||||
|
_mix_wav(note, wav_path)
|
||||||
|
command = [ffmpeg_path, "-hide_banner", "-loglevel", "error", "-y",
|
||||||
|
"-f", "rawvideo", "-pix_fmt", "rgba", "-s:v", f"{WIDTH}x{HEIGHT}",
|
||||||
|
"-r", str(note.metadata["fps"]), "-i", "-"]
|
||||||
|
if has_audio:
|
||||||
|
command += ["-i", wav_path]
|
||||||
|
command += ["-map", "0:v:0"]
|
||||||
|
if has_audio:
|
||||||
|
command += ["-map", "1:a:0", "-c:a", "aac", "-b:a", "192k"]
|
||||||
|
command += ["-c:v", "libx264", "-preset", preset, "-crf", str(video_crf),
|
||||||
|
"-pix_fmt", "yuv420p", "-r", str(note.metadata["fps"]),
|
||||||
|
"-movflags", "+faststart", str(target)]
|
||||||
|
process = subprocess.Popen(command, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
assert process.stdin is not None
|
||||||
|
try:
|
||||||
|
for index in range(len(note.frames)):
|
||||||
|
process.stdin.write(render_frame(note, index).tobytes())
|
||||||
|
process.stdin.close()
|
||||||
|
stderr = process.stderr.read() if process.stderr else b""
|
||||||
|
if process.stderr:
|
||||||
|
process.stderr.close()
|
||||||
|
status = process.wait()
|
||||||
|
except BaseException:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
raise
|
||||||
|
if status:
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
raise RuntimeError(f"ffmpeg conversion failed ({status}): {stderr.decode('utf-8', 'replace').strip()}")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AudioTrack", "SppmError", "SppmNote", "get_sppm_metadata", "get_sppm_thumbnail",
|
||||||
|
"read_sppm", "render_frame", "sppm_to_mp4"]
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sppm_tools import get_sppm_metadata, get_sppm_thumbnail, read_sppm, sppm_to_mp4
|
||||||
|
|
||||||
|
|
||||||
|
def fixture(version: int = 2, speed: int = 0) -> bytes:
|
||||||
|
payload = bytearray()
|
||||||
|
palette = bytearray(256 * 4)
|
||||||
|
palette[4:8] = bytes((255, 0, 0, 255))
|
||||||
|
payload.extend(palette)
|
||||||
|
|
||||||
|
def text(value: str) -> None:
|
||||||
|
encoded = value.encode("utf-8")
|
||||||
|
payload.extend(struct.pack("<H", len(encoded)))
|
||||||
|
payload.extend(encoded)
|
||||||
|
|
||||||
|
for value in ("Python fixture", "", "Tester", "Tester", "Tester"):
|
||||||
|
text(value)
|
||||||
|
for value in ("1111111111111111", "1111111111111111", "1111111111111111",
|
||||||
|
"2222222222222222", "2222222222222222", "0000000000000000"):
|
||||||
|
payload.extend(value.encode("ascii"))
|
||||||
|
payload.extend(struct.pack("<QQI", 100, 100, 0))
|
||||||
|
|
||||||
|
zero_runs = bytearray()
|
||||||
|
for _ in range(1800):
|
||||||
|
zero_runs.extend((255, 0)) # 128 transparent pixels per packet
|
||||||
|
first_layer = bytes((0, 1)) + bytes(zero_runs[:3598]) + bytes((254, 0))
|
||||||
|
for layer in range(4):
|
||||||
|
encoded = first_layer if layer == 0 else bytes(zero_runs)
|
||||||
|
payload.extend(struct.pack("<BII", 1, len(encoded), zlib.crc32(encoded) & 0xffffffff))
|
||||||
|
payload.extend(encoded)
|
||||||
|
|
||||||
|
pcm = struct.pack("<800h", *([1200, -1200] * 400))
|
||||||
|
payload.extend(struct.pack("<BBB I", 0, 1, (6 if version >= 3 and speed else 12), 8000))
|
||||||
|
text("BGM")
|
||||||
|
payload.extend(struct.pack("<HII", 0, 800, zlib.crc32(pcm) & 0xffffffff))
|
||||||
|
payload.extend(pcm)
|
||||||
|
|
||||||
|
header = bytearray(b"SPPM")
|
||||||
|
header.extend(struct.pack("<HHQ I HH B H B B I B H", version, 36, 36 + len(payload),
|
||||||
|
zlib.crc32(payload) & 0xffffffff, 640, 360, 4, 256,
|
||||||
|
12, 2, 1, 1, speed))
|
||||||
|
assert len(header) == 36
|
||||||
|
return bytes(header + payload)
|
||||||
|
|
||||||
|
|
||||||
|
class SppmToolTests(unittest.TestCase):
|
||||||
|
def test_metadata_thumbnail_and_layer_composite(self):
|
||||||
|
note = read_sppm(fixture())
|
||||||
|
self.assertEqual(get_sppm_metadata(fixture())["title"], "Python fixture")
|
||||||
|
self.assertEqual(note.metadata["frameCount"], 1)
|
||||||
|
self.assertEqual(note.audio_tracks[0].name, "BGM")
|
||||||
|
self.assertEqual(len(note.audio_tracks[0].pcm), 800)
|
||||||
|
image = get_sppm_thumbnail(fixture(), size=(640, 360))
|
||||||
|
self.assertEqual(image.getpixel((0, 0)), (255, 0, 0, 255))
|
||||||
|
self.assertEqual(image.getpixel((1, 0)), (255, 255, 255, 255))
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("ffmpeg"), "ffmpeg not installed")
|
||||||
|
def test_mp4_conversion(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
output = Path(directory) / "fixture.mp4"
|
||||||
|
result = sppm_to_mp4(fixture(), output)
|
||||||
|
self.assertEqual(result, output)
|
||||||
|
self.assertGreater(output.stat().st_size, 500)
|
||||||
|
|
||||||
|
def test_rejects_corrupt_file(self):
|
||||||
|
broken = bytearray(fixture())
|
||||||
|
broken[80] ^= 1
|
||||||
|
with self.assertRaisesRegex(ValueError, "CRC"):
|
||||||
|
read_sppm(broken)
|
||||||
|
|
||||||
|
def test_reads_dsi_speed_indices_using_measured_rates(self):
|
||||||
|
note = read_sppm(fixture(version=3, speed=1))
|
||||||
|
self.assertEqual(note.metadata["speed"], 1)
|
||||||
|
self.assertEqual(note.metadata["fps"], 0.4985)
|
||||||
|
self.assertEqual(note.audio_tracks[0].reference_speed, 6)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user
