All migration steps necessary in reading apps to upgrade to major versions of the Kotlin Readium toolkit will be documented in this file.
Readium is now distributed with Maven Central. You must update your Gradle configuration.
allprojects {
repositories {
- maven { url 'https://jitpack.io' }
+ mavenCentral()
}
}
The group ID of the Readium modules is now org.readium.kotlin-toolkit
, for instance:
dependencies {
implementation "org.readium.kotlin-toolkit:readium-shared:$readium_version"
implementation "org.readium.kotlin-toolkit:readium-streamer:$readium_version"
implementation "org.readium.kotlin-toolkit:readium-navigator:$readium_version"
implementation "org.readium.kotlin-toolkit:readium-opds:$readium_version"
implementation "org.readium.kotlin-toolkit:readium-lcp:$readium_version"
}
Decoration.extras
is now a Map<String, Any>
instead of Bundle
. You will need to update your app if you were storing custom data in extras
, for example:
val decoration = Decoration(...,
extras = mapOf("id" to id)
)
val id = decoration.extras["id"] as? Long
The PDF navigator got refactored to support arbitrary third-party PDF engines. As a consequence, PdfiumAndroid (the open source PDF renderer we previously used) was extracted into its own adapter package. This is a breaking change if you were supporting PDF in your application.
This new version ships with an adapter for the commercial PDF engine PSPDFKit, see the instructions under readium/adapter/pspdfkit
to set it up.
If you wish to keep using the open source library PdfiumAndroid, you need to migrate your app.
First, add the new dependency in your app's build.gradle
.
dependencies {
implementation "com.github.readium.kotlin-toolkit:readium-adapter-pdfium:$readium_version"
// Or, if you need only the parser but not the navigator:
implementation "com.github.readium.kotlin-toolkit:readium-adapter-pdfium-document:$readium_version"
}
Then, setup the Streamer
with the adapter factory:
Streamer(...,
pdfFactory = PdfiumDocumentFactory(context)
)
Finally, provide the new PdfiumEngineProvider
to PdfNavigatorFactory
:
val navigatorFactory = PdfNavigatorFactory(
publication = publication,
pdfEngineProvider = PdfiumEngineProvider()
)
override fun onCreate(savedInstanceState: Bundle?) {
childFragmentManager.fragmentFactory =
navigatorFactory.createFragmentFactory(...)
super.onCreate(savedInstanceState)
}
The local HTTP server is not needed anymore to render EPUB publications. You can safely drop all occurrences of Server
from your project and remove the baseUrl
parameter when calling EpubNavigatorFragment.createFactory()
.
If you were serving assets/
files (e.g. fonts or scripts) to the EPUB resources, you can still do so with the new API.
First, declare the assets/
paths that will be available to EPUB resources when creating the navigator. You can use simple glob patterns to allow multiple assets in one go, e.g. fonts/.*
.
EpubNavigatorFragment.createFactory(
...,
config = EpubNavigatorFragment.Configuration(
servedAssets = listOf(
"fonts/.*",
"annotation-icon.svg"
)
)
)
Then, use the base URL https://readium/assets/
to fetch your app assets from the web views. For example:
https://readium/assets/annotation-icon.svg
After removing the HTTP server, tapping on the edge of the screen will not turn pages anymore. If you wish to keep this behavior, you can add it in your app by implementing VisualNavigator.Listener.onTap()
. An instance of EdgeTapNavigation
can help to compute the page turns by taking into account the publication reading progression and custom thresholds. See an example in the test app.
override fun onTap(point: PointF): Boolean {
val navigated = EdgeTapNavigation(navigator).onTap(point, requireView())
if (!navigated) {
toggleAppBar()
}
return true
}
The 2.3.0 release introduces a brand new user preferences API for configuring the EPUB and PDF Navigators. This new API is easier and safer to use. To learn how to integrate it in your app, please refer to the user guide.
If you integrated the EPUB navigator from a previous version, follow these steps to migrate:
- Get familiar with the concepts of this new API.
- Remove the local HTTP server from your app, as explained in the previous section.
- Remove the whole
UserSettings.kt
file from your app, if you copied it from the Test App. - Adapt your user settings interface to the new API using preferences editors. The Test App and the user guide contain examples using Jetpack Compose.
- Handle the persistence of the user preferences. The settings are not stored in the
SharedPreferences
with nameorg.readium.r2.settings
anymore. Instead, you are responsible for persisting and restoring the user preferences as you see fit (e.g. as a JSON file).- If you want to migrate the legacy
SharedPreferences
settings, you can use the helperEpubPreferences.fromLegacyEpubSettings()
which will create a newEpubPreferences
object after translating the existing user settings.
- If you want to migrate the legacy
- Make sure you restore the stored user preferences when initializing the EPUB navigator.
Please refer to the following table for the correspondence between legacy settings and new ones.
Legacy | New |
---|---|
APPEARANCE_REF |
theme |
COLUMN_COUNT_REF |
columnCount (reflowable) and spread (fixed-layout) |
FONT_FAMILY_REF |
fontFamily |
FONT_OVERRIDE_REF |
N/A (handled automatically) |
FONT_SIZE_REF |
fontSize |
LETTER_SPACING_REF |
letterSpacing |
LINE_HEIGHT_REF |
lineHeight |
PAGE_MARGINS_REF |
pageMargins |
PUBLISHER_DEFAULT_REF |
publisherStyles |
reader_brightness |
N/A (out of scope for Readium) |
SCROLL_REF |
overflow (scrolled ) |
TEXT_ALIGNMENT_REF |
textAlign |
WORD_SPACING_REF |
wordSpacing |
publication.userSettingsUIPreset
is now deprecated, but you might still have this code in your application:
publication.userSettingsUIPreset[ReadiumCSSName.ref(SCROLL_REF)] = true
You can remove it, as the support for screen readers will be added directly to the navigator in a coming release. However if you want to keep it, here is the equivalent with the new API:
navigator.submitPreferences(currentPreferences.copy(scroll = true))
This hotfix release fixes an issue pulling a third-party dependency (NanoHTTPD) from JitPack.
After upgrading, make sure to remove the dependency to NanoHTTPD from your app's build.gradle
file before building:
-implementation("com.github.edrlab.nanohttpd:nanohttpd:master-SNAPSHOT") {
- exclude(group = "org.parboiled")
-}
-implementation("com.github.edrlab.nanohttpd:nanohttpd-nanolets:master-SNAPSHOT") {
- exclude(group = "org.parboiled")
-}
☝️ If you are stuck with an older version of Readium, you can use this workaround in your root build.gradle
, as an alternative.
With this new release, we migrated all the r2-*-kotlin
repositories to a single kotlin-toolkit
repository.
If you are integrating Readium with the JitPack Maven repository, the same Readium modules are available as before. Just replace the former dependency notations with the new ones, per the README.
dependencies {
implementation "com.github.readium.kotlin-toolkit:readium-shared:$readium_version"
implementation "com.github.readium.kotlin-toolkit:readium-streamer:$readium_version"
implementation "com.github.readium.kotlin-toolkit:readium-navigator:$readium_version"
implementation "com.github.readium.kotlin-toolkit:readium-opds:$readium_version"
implementation "com.github.readium.kotlin-toolkit:readium-lcp:$readium_version"
}
If you are integrating your own forks of the Readium modules, you will need to migrate them to a single fork and port your changes. Follow strictly the given steps and it should go painlessly.
- Upgrade your forks to the latest Readium 2.1.0 version from the legacy repositories, as you would with any update. The 2.1.0 version is available on both the legacy repositories and the new
kotlin-toolkit
one. It will be used to port your changes over to the single repository. - Fork the new
kotlin-toolkit
repository on your own GitHub space. - In a new local directory, clone your legacy forks as well as the new single fork:
mkdir readium-migration cd readium-migration # Clone the legacy forks git clone https://github.com/USERNAME/r2-shared-kotlin.git git clone https://github.com/USERNAME/r2-streamer-kotlin.git git clone https://github.com/USERNAME/r2-navigator-kotlin.git git clone https://github.com/USERNAME/r2-opds-kotlin.git git clone https://github.com/USERNAME/r2-lcp-kotlin.git # Clone the new single fork git clone https://github.com/USERNAME/kotlin-toolkit.git
- Reset the new fork to be in the same state as the 2.1.0 release.
cd kotlin-toolkit git reset --hard 2.1.0
- For each Readium module, port your changes over to the new fork.
rm -rf readium/*/src cp -r ../r2-shared-kotlin/r2-shared/src readium/shared cp -r ../r2-streamer-kotlin/r2-streamer/src readium/streamer cp -r ../r2-navigator-kotlin/r2-navigator/src readium/navigator cp -r ../r2-opds-kotlin/r2-opds/src readium/opds cp -r ../r2-lcp-kotlin/r2-lcp/src readium/lcp
- Review your changes, then commit.
git add readium git commit -m "Apply local changes to Readium"
- Finally, pull the changes to upgrade to the latest version of the fork. You might need to fix some conflicts.
git pull --rebase git push
Your fork is now ready! To integrate it in your app as a local Git clone or submodule, follow the instructions from the README.
Nothing to change in your app to upgrade from 2.0.0-beta.2 to the final 2.0.0 release! Please follow the relevant sections if you are upgrading from an older version.
This new beta is the last one before the final 2.0.0 release. It is mostly focused on bug fixes but we also adjusted the LCP and HTTP server APIs before setting it in stone for the 2.x versions.
The API used to serve Publication
resources with the Streamer's HTTP server was simplified. See the test app changes in PR #387.
Replace addEpub()
with addPublication()
, which does not expect the publication filename anymore. If the Publication
is servable, addPublication()
will return its base URL. This means that you do not need to:
- Call
Publication.localBaseUrlOf()
to get the base URL. Use the one returned byaddPublication()
instead. - Set the server port in the
$key-publicationPort
SharedPreferences
property.- If you copied the
R2ScreenReader
from the test app, you will need to update it to use directly the base URL instead of the$key-publicationPort
property. See this commit.
- If you copied the
R2EpubActivity
and R2AudiobookActivity
are expecting an additional Intent
extra: baseUrl
. Use the base URL returned by addPublication()
.
Find all the changes made in the test app related to LCP in PR #379.
We replaced all occurrences of Joda's DateTime
with java.util.Date
in r2-lcp-kotlin
, to reduce the dependency on third-party libraries. You will need to update any code using LcpLicense
. The easiest way would be to keep using Joda in your own app and create DateTime
object from the Date
ones. For example:
lcpLicense?.license?.issued?.let { DateTime(it) }
The API to renew an LCP loan got revamped to better support renewal through a web page. You will need to implement LcpLicense.RenewListener
to coordinate the UX interaction.
If your application fits Material Design guidelines, you may use the provided MaterialRenewListener
implementation directly. This will only work if your theme extends a MaterialComponents
one, for example:
<style name="AppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
MaterialRenewListener
expects an ActivityResultCaller
instance for argument. Any ComponentActivity
or Fragment
object can be used as ActivityResultCaller
.
val activity: FragmentActivity
license.renewLoan(MaterialRenewListener(
license = lcpLicense,
caller = activity,
fragmentManager = activity.supportFragmentManager
))
The version 2.0.0-beta.1 is mostly stabilizing the new APIs and fixing existing bugs. We also upgraded the libraries to be compatible with Kotlin 1.4 and Gradle 4.1.
To simplify the new format API, we merged Format
into MediaType
to offer a single interface. If you were using Format
, you should be able to replace it by MediaType
seamlessly.
Streamer.open()
is now expecting an implementation of PublicationAsset
instead of an instance of File
. This allows to open publications which are not represented as files on the device. For example a stream, an URL or any other custom structure.
Readium ships with a default implementation named FileAsset
replacing the previous File
type. The API is the same so you can just replace File
by FileAsset
in your project.
This new version is now compatible with display cutouts. However, this is an opt-in feature. To support display cutouts, follow these instructions:
- IMPORTANT: You need to remove any
setPadding()
statement from your app inUserSettings.kt
, if you copied it from the test app. - If you embed a navigator fragment (e.g.
EpubNavigatorFragment
) yourself, you need to opt-in by specifying thelayoutInDisplayCutoutMode
of the hostActivity
. R2EpubActivity
andR2CbzActivity
automatically applyLAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
to their window'slayoutInDisplayCutoutMode
.PdfNavigatorFragment
is not yet compatible with display cutouts, because of limitations from the underlying PDF viewer.
The 2.0.0 introduces numerous new APIs in the Shared Models, Streamer and LCP libraries, which are detailed in the following proposals. We highly recommend skimming over the "Developer Guide" section of each proposal before upgrading to this new major version.
- Format API
- Composite Fetcher API
- Publication Encapsulation
- Publication Helpers and Services
- Streamer API
- Content Protection
This r2-testapp-kotlin
commit showcases all the changes required to upgrade the Test App.
Please reach out on Slack if you have any issue migrating your app to Readium 2.0.0, after checking the troubleshooting section.
A new Streamer
class deprecates the use of individual PublicationParser
implementations, which you will need to replace in your app.
Call Streamer::open()
to parse a publication. It will return a self-contained Publication
model which handles metadata, resource access and DRM decryption. This means that Container
, PubBox
and DRM
are not needed anymore, you can remove any reference from your app.
The allowUserInteraction
parameter should be set to true
if you intend to render the parsed publication to the user. It will allow the Streamer to display interactive dialogs, for example to enter DRM credentials. You can set it to false
if you're parsing a publication in a background process, for example during bulk import.
val streamer = Streamer(context)
val publication = streamer.open(File(path), allowUserInteraction = true)
.getOrElse { error ->
alert(error.getUserMessage(context))
return
}
You can't use Publication.fromJSON()
to parse directly a manifest anymore. Instead, you can use Manifest.fromJSON()
, which gives you access to the metadata embedded in the manifest.
Then, if you really need a Publication
model, you can build one yourself from the Manifest
and optionally a Fetcher
and Publication Services.
-val publication = Publication.fromJSON(json)
+val publication = Manifest.fromJSON(json)?.let { Publication(it) }
However, the best way to parse a RWPM is to use the Streamer
, like with any other publication format. This way the Publication
model will be initialized with appropriate Fetcher
and Publication Services.
In case of failure, a Publication.OpeningException
is returned. It implements UserException
and can be used directly to present an error message to the user with getUserMessage(Context)
.
If you wish to customize the error messages or add translations, you can override the strings declared in r2-shared-kotlin/r2-shared/src/main/res/values/strings.xml
in your own app module. This goes for LCP errors as well, which are declared in r2-lcp-kotlin/r2-lcp/src/main/res/values/strings.xml
.
Streamer
offers other useful APIs to extend the capabilities of the Readium toolkit. Take a look at its documentation for more details, but here's an overview:
- Add new custom parsers.
- Integrated DRM support, such as LCP.
- Provide different implementations for third-party tools, e.g. ZIP, PDF and XML.
- Customize the
Publication
's metadata orFetcher
upon creation. - Collect authoring warnings from parsers.
Since the new Publication
model is self-contained, you can replace any use of the Container
API by publication.get(Link)
. This works for any publication format supported by the Streamer
's parsers.
The test app used to have special cases for DiViNa and Audiobooks, by unpacking manually the ZIP archives. You should remove this code and streamline any resource access using publication.get()
.
Extracting the cover of a publication for caching purposes can be done with a single call to publication.cover()
, instead of reaching for a Link
with cover
relation. You can use publication.coverFitting(Size)
to select the best resolution without exceeding a given size. It can be useful to avoid saving very large cover images.
-val cover =
- try {
- publication.coverLink
- ?.let { container.data(it.href) }
- ?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
- } catch (e: Exception) {
- null
- }
+val cover = publication.coverFitting(Size(width = 100, height = 100))
Navigator::currentLocator
is now a StateFlow
instead of LiveData
, to better support chromeless navigators such as an audiobook navigator in the future.
If you were observing currentLocator
from an Activity
or Fragment
, you can continue to do so with currentLocator.asLiveData()
.
- navigator.currentLocator.observe(this, Observer { locator -> })
+ navigator.currentLocator.asLiveData().observe(this, Observer { locator -> })
If you access directly the value through navigator.currentLocator.value
, you might need to add the following annotation to the enclosing class:
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
Despite being still experimental, StateFlow
is deemed stable for use.
Support for LCP is now fully integrated with the Streamer
, which means that you don't need to retrieve the LCP license and fill container.drm
yourself after opening a Publication
anymore.
To enable the support for LCP in the Streamer
, you need to initialize it with a ContentProtection
implementation provided by r2-lcp-kotlin
.
val lcpService = LcpService(context)
val streamer = Streamer(
context = context,
contentProtections = listOfNotNull(
lcpService?.contentProtection()
)
)
Then, to prompt the user for their passphrase, you need to set allowUserInteraction
to true
and provide the instance of the hosting Activity
, Fragment
or View
with the sender
parameter when opening the publication.
streamer.open(File(path), allowUserInteraction = true, sender = activity)
Alternatively, if you already have the passphrase, you can pass it directly to the credentials
parameter. If it's valid, the user won't be prompted.
The LCP Service now ships with a default passphrase dialog. You can remove the former implementation from your app if you copied it from the test app. But if you still want to use a custom implementation of LcpAuthenticating
, for example to have a different layout, you can pass it when creating the ContentProtection
.
lcpService.contentProtection(CustomLCPAuthentication())
In case the credentials were incorrect or missing, the Streamer
will still return a Publication
, but in a "restricted" state. This allows reading apps to import publications by accessing their metadata without having the passphrase.
But if you need to present the publication with a Navigator, you will need to first check if the Publication
is not restricted.
Besides missing credentials, a publication can be restricted if the Content Protection returned an error, for example when the publication is expired. In which case, you must display the error to the user by checking the presence of a publication.protectionError
.
if (publication.isRestricted) {
publication.protectionError?.let { error ->
// A status error occurred, for example the publication expired
alert(error.getUserMessage(context))
}
} else {
presentNavigator(publication)
}
To check if a publication is protected with a known DRM, you can use publication.isProtected
.
If you need to access an LCP license's information, you can use the helper publication.lcpLicense
, which will return the LcpLicense
if the publication is protected with LCP and the passphrase was known. Alternatively, you can use LcpService::retrieveLicense()
as before.
LcpService.importPublication()
was replaced with acquirePublication()
, which is a cancellable suspending function. It doesn't require the user to enter its passphrase anymore to download the publication.
You can integrate additional DRMs, such as Adobe ACS, by implementing the ContentProtection
protocol. This will provide first-class support for this DRM in the Streamer and Navigator.
Take a look at the Content Protection proposal for more details. An example implementation can be found in r2-lcp-kotlin
.
A few of the new APIs are returning a Try
object, which is similar to the native Result
type. We decided to go for this opiniated approach for error handling instead of throwing Exception
because of the type-safety it brings and the constraint on reading apps to properly handle error cases.
You can revert to traditional exceptions by calling getOrThrow()
on the Try
instance, but the most convenient way to handle the error would be to use getOrElse()
.
val publication = streamer.open(File(path), allowUserInteraction = true)
.getOrElse { error ->
alert(error.getUserMessage(context))
return
}
Try
also supports map()
and flatMap()
which are useful to transform the result while forwarding any error handling to upper layers.
fun cover(): Try<Bitmap, ResourceException> =
publication.get(coverLink)
.use { resource -> resource.read() } // <- returns a Try<ByteArray, ResourceException>
.map { bytes -> BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }
Attempt to invoke virtual method 'android.content.SharedPreferences android.content.Context.getSharedPreferences(java.lang.String, int)
' on a null object reference
Make sure you create the LcpService
after onCreate()
has been called on an Activity
.
Make sure you added the following to your app's build.gradle
:
implementation "readium:liblcp:1.0.0@aar"
Make sure you added the content protection to the Streamer, following these instructions.
E/LcpDialogAuthentication: No valid [sender] was passed to LcpDialogAuthentication::retrievePassphrase()
. Make sure it is an Activity, a Fragment or a View.
To be able to present the LCP passphrase dialog, the default LcpDialogAuthentication
needs a hosting view as context. You must provide it to the sender
parameter of Streamer::open()
.
streamer.open(File(path), allowUserInteraction = true, sender = activity)
IllegalArgumentException: The provided publication is restricted. Check that any DRM was properly unlocked using a Content Protection.
Navigators will refuse to be opened if a publication is protected and not unlocked. You must check if a publication is not restricted by following these instructions.
With this new release, we started a process of modernization of the Readium Kotlin toolkit to:
- better follow Android best practices and Kotlin conventions,
- reduce coupling between reading apps and Readium, to ease future migrations and allow refactoring of private core implementations,
- increase code safety,
- unify Readium APIs across platforms through public specifications.
As such, this release will break existing codebases. While most changes are facilitated thanks to deprecation warnings with automatic fixes, there are a few changes listed below that you will need to operate manually.
- The
Publication
shared models were moved to their own package. While there are deprecated aliases helping with migration, it doesn't work forPublication.EXTENSION
. Therefore, you need to replace all occurrences oforg.readium.r2.shared.Publication
byorg.readium.r2.shared.publication.Publication
in your codebase.- Or better, don't use
Publication.EXTENSION
anymore. We have a newFormat
API which handles these needs in a more systematic way. And instead of file extensions, we recommend to store media types in your database.
- Or better, don't use
- A few
Publication
andLink
properties, such asimages
,pageList
andnumberOfItems
were moved to a different package. Simply trigger the "Import" feature of your IDE to resolve them.
The Publication
shared models are now immutable to increase code safety. This should not impact reading apps much unless you were creating Publication
or other models yourself.
However, there are a few places in the Test App that needs to be updated:
Publication
'sreadingOrder
,links
, andtableOfContents
are notMutableList
anymore, but read-onlyList
. Therefore, you need to update any code expecting mutable lists.Locator
can't be modified directly anymore. Instead, use thecopy()
orcopyWithLocations()
Locator
APIs.
Best practices on observing and restoring the last location were updated in the Test App, and it is highly recommended that you update your codebase as well, to avoid any issues.
You need to make these changes in your implementations of EpubActivity
, ComicActivity
and AudiobookActivity
:
- Remove any overrides of
currentLocation
. - Restore the last location from your database in
onCreate()
, for example with something similar to:
// Restores the last read location
bookRepository.lastLocatorOfBook(bookId)?.let { locator ->
go(locator, animated = false)
}
NavigatorDelegate.locationDidChange()
is now deprecated in favor of the more idiomatic Navigator.currentLocator: LiveData<Locator?>
.
currentLocator.observe(this, Observer { locator ->
if (locator != null) {
bookRepository.saveLastLocatorOfBook(bookId, locator)
}
})
- A new Positions List feature was added to provide a list of discrete locations in a publication. It can be used to implement an approximation of page numbers. This replaces the existing
R2SyntheticPageList
, which should be removed from your codebase. - Publications parsed from large manifests could crash the application when starting a reading activity. To fix this,
Publication
must not be put in anIntent
extra anymore. Instead, use the newIntent
extensions provided by Readium. This solution is a crutch until we move away fromActivity
in the Navigator and let reading apps handle the lifecycle ofPublication
themselves.- Replace all occurrences of
putExtra("publication", publication)
or similar byputPublication(publication)
. - Replace all occurrences of
getSerializableExtra("publication")
or similar bygetPublication(this)
.
- Replace all occurrences of
Locator
is nowParcelable
instead ofSerializable
, you must replace all occurrences ofgetSerializableExtra("locator")
bygetParcelableExtra("locator")
.Locations.fragment
was renamed tofragments
, and is now aList
. You need to update your code if you were creatingLocations
yourself.locations
andtext
are not nullable anymore.Locator
's constructor has a default value, so you don't need to passnull
for them anymore.Locator
is not meant to be subclassed, and extending it is not possible anymore. If your project is based on the Test App, you need to do the following changes in your codebase:- Don't extend
Locator
inBookmark
andHighlight
. Instead, add alocator
property which will create aLocator
object from their properties. Then, in places where you were creating aLocator
from a database model, you can use this property directly. - For
SearchLocator
, you have two choices:- (Recommended) Replace all occurrences of
SearchLocator
byLocator
. These two models are interchangeable. - Use the same strategy described above for
Bookmark
.
- (Recommended) Replace all occurrences of
- Don't extend
class Bookmark(...) {
val locator get() = Locator(
href = resourceHref,
type = resourceType,
title = resourceTitle,
locations = location,
text = locatorText
)
}
The CSS, JavaScript and fonts injection in the Server
was refactored to reduce the risk of collisions and simplify your codebase. This is a breaking change, to upgrade your app you need to:
- Provide the application's
Context
when creating aServer
. - Remove the following injection statements, which are now handled directly by the Streamer:
server.loadCustomResource(assets.open("scripts/crypto-sha256.js"), "crypto-sha256.js", Injectable.Script)
server.loadCustomResource(assets.open("scripts/highlight.js"), "highlight.js", Injectable.Script)