diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a21bd27..f551a1d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -78,6 +78,7 @@ jobs: ci/baseline/libcustomnotebooktemplates.so mods/custom-notebook-templates/libcustomnotebooktemplates.so --tool-prefix arm-linux-gnueabihf- + --expected-symbol-delta ci/notebook-sleep-abi-additions.json - name: Validate installer contents and binary identity run: >- python scripts/verify-release-package.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c42e168..d58e293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to KoboNotebookPlus will be documented in this file. +## [0.0.3-alpha] - 2026-07-17 + +### Added + +- Add **Show notebook on sleep** to Kobo's native Energy saving and privacy + settings, with **Cover** and **Current page** image choices. +- Show a notebook image only when sleep or power-off starts from a visible + Advanced Notebook; books and all other views keep Kobo's stock cover flow. +- Composite current-page ink over the active built-in or custom paper + template while preserving the notebook's live zoom, pan, and canvas offset. + +### Fixed + +- Suppress the stale book reading-status panel while a notebook image is + substituted on the power view. +- Interpret MyScript export extents as left/top/right/bottom coordinates and + apply the live renderer transform, preventing ink from being centered, + shifted, stretched, or clipped by its content bounding box. +- Match Kobo's native two-column settings layout by placing the setting label + on the left and the checkbox's **On** control on the right. +- Fall back safely to the normal book cover whenever the notebook is hidden, + busy, invalid, or cannot be rendered. + +### Verification + +- Tested on Kobo Elipsa 2E (`condor`) firmware `4.38.23697` for notebook and + book sleep routing, current-page ink/template alignment, and native settings + alignment. +- Verified button sleep and power-off behavior with on-device notebook pages; + the release remains pinned to this exact device and firmware. + ## [0.0.2-alpha] - 2026-07-17 ### Fixed diff --git a/README.md b/README.md index 61575fd..6571b03 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # KoboNotebookPlus -**Native layers, custom paper templates, writable covers, page tools, and a -configurable eraser for Kobo Advanced Notebooks.** +**Native layers, custom paper templates, writable covers, notebook sleep +screens, page tools, and a configurable eraser for Kobo Advanced Notebooks.** KoboNotebookPlus is a [NickelHook](https://github.com/pgaskin/NickelHook) plugin that extends the stock Advanced Notebook experience on the Kobo @@ -70,6 +70,10 @@ Click any preview to view it at full size. - **Custom paper templates** — drop in your own full-page PNG templates. - **Writable covers** — custom notebook covers you can write on as a first page. +- **Notebook sleep screens** — when sleep or power-off starts from a visible + Advanced Notebook, show either its cover (page zero) or its current page, + including the active built-in or custom paper template; books and every + non-notebook view retain Kobo's stock cover behavior. - **Page operations** — duplicate and reorder notebook pages. - **Clean library** — plugin support images stay out of Home/My Books without touching the Kobo database. @@ -186,6 +190,10 @@ If Nickel crash-loops or notebooks misbehave after install: - Inside an Advanced Notebook, the notebook menu gains Covers, Layers, and page operations. The eraser popup shows the stock mode choices plus the size row. +- Under **Settings → Energy saving and privacy**, enable **Show notebook on + sleep**, then choose **Cover** or **Current page** under **Notebook sleep + image**. The selector is disabled until the toggle is enabled. Rendering + failure or a busy/hidden notebook falls back to Kobo's normal book cover. Layer metadata and preview caches live under `.kobo/custom/layers/`; native ink stays inside the `.nebo` document. Backups created by cover, page, and diff --git a/ci/notebook-sleep-abi-additions.json b/ci/notebook-sleep-abi-additions.json new file mode 100644 index 0000000..d2499c3 --- /dev/null +++ b/ci/notebook-sleep-abi-additions.json @@ -0,0 +1,42 @@ +{ + "candidate_only_exports": [ + {"kind": "T", "name": "_cnt_power_cover_loaded_hook"}, + {"kind": "T", "name": "_cnt_power_set_info_panel_visible_hook"}, + {"kind": "T", "name": "_cnt_power_update_cover_hook"}, + {"kind": "T", "name": "_cnt_power_update_reading_status_hook"}, + {"kind": "T", "name": "_cnt_settings_power_view_constructor_hook"}, + {"kind": "T", "name": "_cnt_show_power_off_view_hook"}, + {"kind": "T", "name": "_cnt_show_sleep_view_hook"} + ], + "candidate_only_imports": [ + {"kind": "U", "name": "_ZN10QBoxLayout9addWidgetEP7QWidgeti6QFlagsIN2Qt13AlignmentFlagEE"}, + {"kind": "U", "name": "_ZN10QJsonValueC1E13QLatin1String"}, + {"kind": "U", "name": "_ZN10QJsonValueC1Eb"}, + {"kind": "U", "name": "_ZN10QTransformC1Edddddd"}, + {"kind": "U", "name": "_ZN11QVBoxLayout16staticMetaObjectE"}, + {"kind": "U", "name": "_ZN4QDir8tempPathEv"}, + {"kind": "U", "name": "_ZN6QImage4fillEN2Qt11GlobalColorE"}, + {"kind": "U", "name": "_ZN6QImageC1ERK5QSizeNS_6FormatE"}, + {"kind": "U", "name": "_ZN7QLocaleC1Ev"}, + {"kind": "U", "name": "_ZN7QLocaleD1Ev"}, + {"kind": "U", "name": "_ZN7QObject7connectEPKS_PKcS1_S3_N2Qt14ConnectionTypeE"}, + {"kind": "U", "name": "_ZN7QRegionD1Ev"}, + {"kind": "U", "name": "_ZN7QWidget10setEnabledEb"}, + {"kind": "U", "name": "_ZN8QPainter13setRenderHintENS_10RenderHintEb"}, + {"kind": "U", "name": "_ZN8QPainter17setWorldTransformERK10QTransformb"}, + {"kind": "U", "name": "_ZN8QPainter18setCompositionModeENS_15CompositionModeE"}, + {"kind": "U", "name": "_ZN8QPainter9drawImageERK6QRectFRK6QImageS2_6QFlagsIN2Qt19ImageConversionFlagEE"}, + {"kind": "U", "name": "_ZN8QVariantC1Ei"}, + {"kind": "U", "name": "_ZNK10QJsonValue4typeEv"}, + {"kind": "U", "name": "_ZNK10QJsonValue6toBoolEb"}, + {"kind": "U", "name": "_ZNK10QTransform7mapRectERK6QRectF"}, + {"kind": "U", "name": "_ZNK11QMetaObject4castEP7QObject"}, + {"kind": "U", "name": "_ZNK6QImage4rectEv"}, + {"kind": "U", "name": "_ZNK6QRectF10intersectsERKS_"}, + {"kind": "U", "name": "_ZNK7QRegion7isEmptyEv"}, + {"kind": "U", "name": "_ZNK7QWidget13visibleRegionEv"}, + {"kind": "U", "name": "_ZNK7QWidget5mapToEPKS_RK6QPoint"}, + {"kind": "U", "name": "_ZNK7QWidget6layoutEv"}, + {"kind": "U", "name": "_ZNK7QWidget6windowEv"} + ] +} diff --git a/mods/custom-notebook-templates/Makefile b/mods/custom-notebook-templates/Makefile index 4bf12ef..3a81a7c 100644 --- a/mods/custom-notebook-templates/Makefile +++ b/mods/custom-notebook-templates/Makefile @@ -8,10 +8,10 @@ override LIBRARY := libcustomnotebooktemplates.so override SOURCES += src/firmware_resolver.cc src/fs_util.cc src/settings.cc \ src/templates.cc src/covers.cc src/cover_cache.cc src/cover_editor.cc src/page_io.cc \ src/notebook_widget.cc src/notebook_hook_services.cc src/notebook_menu.cc src/pages.cc \ - src/page_actions.cc src/visibility.cc src/eraser_menu.cc src/layers_state.cc \ + src/page_actions.cc src/notebook_sleep.cc src/visibility.cc src/eraser_menu.cc src/layers_state.cc \ src/layers_service.cc src/layers_preview.cc src/layers_eraser.cc src/layers_menu.cc \ src/plugin_runtime.cc src/plugin_entry.cc src/hooks.cc src/visibility_hooks.cc -override MOCS += src/covermenureceiver.h +override MOCS += src/covermenureceiver.h src/notebooksleepsettingsreceiver.h override PKGCONF += Qt5Widgets override CXXFLAGS += -Wall -Wextra -Werror -Wno-missing-field-initializers \ -fvisibility=hidden -fvisibility-inlines-hidden diff --git a/mods/custom-notebook-templates/src/abi_types.h b/mods/custom-notebook-templates/src/abi_types.h index 3268810..8ef7170 100644 --- a/mods/custom-notebook-templates/src/abi_types.h +++ b/mods/custom-notebook-templates/src/abi_types.h @@ -78,15 +78,38 @@ typedef std::shared_ptr SharedActiveBackend; // atk::core::Extent is a four-float homogeneous aggregate under ARM's // hard-float ABI. PageController::exportToPNG passes it in s0-s3 to its one -// RendererListener callback. +// RendererListener callback. Despite the API name, the last two floats are +// the maximum coordinates, not width and height. BackendImageDrawer proves +// this by calculating its bitmap dimensions from (right - left) and +// (bottom - top). struct ExtentOpaque { - float x; - float y; - float width; - float height; + float left; + float top; + float right; + float bottom; }; static_assert(sizeof(ExtentOpaque) == 16, "unexpected Extent ABI"); +// atk::core::Transform is returned by value through ARM's hidden sret +// pointer. RendererImpl::getViewTransform converts its six internal floats +// to these six doubles in row-major affine order: +// +// viewX = xx * pageX + xy * pageY + x0 +// viewY = yx * pageX + yy * pageY + y0 +// +// Keeping the exact 48-byte return shape is important: declaring this as a +// Qt transform (or as six floats) would shift the implicit `this` argument. +struct RendererViewTransformOpaque { + double xx; + double xy; + double x0; + double yx; + double yy; + double y0; +}; +static_assert(sizeof(RendererViewTransformOpaque) == 48, + "unexpected renderer view-transform ABI"); + // atk::core::Point is two floats. The PointerInfo vector remains opaque: it // is passed by reference to Kobo's own Eraser::strokerPolygon implementation, // so the plugin never needs to construct or inspect a PointerInfo. @@ -219,6 +242,8 @@ typedef void (*DocumentLayoutGetLayer)( typedef bool (*LayerIteratorIsAtEnd)(void const* iterator); typedef void (*ManagedObjectDestructor)(void* object); typedef void* (*RendererGetBackend)(void* renderer); +typedef RendererViewTransformOpaque (*RendererGetViewTransform)( + void const* renderer); typedef void (*RendererRestrictToLayers)( void* renderer, std::vector const& ids); diff --git a/mods/custom-notebook-templates/src/cover_cache.cc b/mods/custom-notebook-templates/src/cover_cache.cc index 8408a06..220607f 100644 --- a/mods/custom-notebook-templates/src/cover_cache.cc +++ b/mods/custom-notebook-templates/src/cover_cache.cc @@ -677,10 +677,23 @@ void cacheRenderedCoverImage( if (!info.isFile() || info.size() <= 0) return; - persistRenderedCoverImage(notebookPath, coverType, image); + QImage cachedImage = image; + if (coverType.isEmpty() + && (cachedImage.width() != kBackgroundWidth + || cachedImage.height() != kBackgroundHeight)) { + cachedImage = cachedImage.scaled( + kBackgroundWidth, + kBackgroundHeight, + Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); + } + if (cachedImage.isNull()) + return; + + persistRenderedCoverImage(notebookPath, coverType, cachedImage); RenderedCoverEntry entry; - entry.image = image; + entry.image = cachedImage; entry.notebookModifiedMs = info.lastModified().toMSecsSinceEpoch(); entry.notebookSize = info.size(); entry.coverType = coverType; diff --git a/mods/custom-notebook-templates/src/cover_cache.h b/mods/custom-notebook-templates/src/cover_cache.h index 82820e7..3237449 100644 --- a/mods/custom-notebook-templates/src/cover_cache.h +++ b/mods/custom-notebook-templates/src/cover_cache.h @@ -25,9 +25,9 @@ struct CoverScanEntry { bool hasCover; }; -// Kobo's own rendered first-page preview, including the selected cover and -// live ink. The parser callback populates this cache; the notebook-card hook -// may reuse it only while the source .nebo has the same mtime and size. +// Kobo's rendered first-page preview and, for plugin covers, the composed +// cover plus live ink. The parser callback populates this cache; notebook +// cards and the notebook sleep screen can both reuse it. struct RenderedCoverEntry { QImage image; qint64 notebookModifiedMs; @@ -71,6 +71,7 @@ struct State { QMutex cacheMutex; bool pickerPending = false; bool titlePending = false; + bool parserHookReady = false; bool hooksReady = false; bool gridHookReady = false; }; diff --git a/mods/custom-notebook-templates/src/firmware_api.h b/mods/custom-notebook-templates/src/firmware_api.h index c67d055..b83430e 100644 --- a/mods/custom-notebook-templates/src/firmware_api.h +++ b/mods/custom-notebook-templates/src/firmware_api.h @@ -71,6 +71,7 @@ struct FirmwareApi { LayerIteratorIsAtEnd layerIteratorIsAtEnd; ManagedObjectDestructor managedObjectDestructor; RendererGetBackend rendererGetBackend; + RendererGetViewTransform rendererGetViewTransform; RendererRestrictToLayers rendererRestrictToLayers; PageControllerExportToPng pageControllerExportToPng; BackendImageDrawerMakeShared backendImageDrawerMakeShared; diff --git a/mods/custom-notebook-templates/src/firmware_pins.h b/mods/custom-notebook-templates/src/firmware_pins.h index 9d7d955..b623ba4 100644 --- a/mods/custom-notebook-templates/src/firmware_pins.h +++ b/mods/custom-notebook-templates/src/firmware_pins.h @@ -84,6 +84,8 @@ static char const kManagedObjectDestructorSymbol[] = "_ZN8myscript6engine13ManagedObjectD1Ev"; static char const kRendererGetBackendSymbol[] = "_ZN8myscript4iink12RendererImpl10getBackendEv"; +static char const kRendererGetViewTransformSymbol[] = + "_ZNK8myscript4iink12RendererImpl16getViewTransformEv"; static char const kRendererRestrictToLayersSymbol[] = "_ZN3atk4core8Renderer16restrictToLayersERKSt6vectorISsSaISsEE"; static char const kPageControllerExportToPngSymbol[] = @@ -191,6 +193,7 @@ static uintptr_t const kDocumentLayoutGetLayerVma = 0x4b3d00; static uintptr_t const kLayerIteratorIsAtEndVma = 0x4b3e88; static uintptr_t const kManagedObjectDestructorVma = 0x3e5390; static uintptr_t const kRendererGetBackendVma = 0x49e318; +static uintptr_t const kRendererGetViewTransformVma = 0x49df8c; static uintptr_t const kRendererRestrictToLayersVma = 0xa57714; static uintptr_t const kPageControllerExportToPngVma = 0x5130c4; static uintptr_t const kBackendImageDrawerMakeSharedVma = 0x443eac; diff --git a/mods/custom-notebook-templates/src/firmware_resolver.cc b/mods/custom-notebook-templates/src/firmware_resolver.cc index dcd0f4a..a6102ba 100644 --- a/mods/custom-notebook-templates/src/firmware_resolver.cc +++ b/mods/custom-notebook-templates/src/firmware_resolver.cc @@ -148,6 +148,9 @@ bool resolveFirmwareApis(void* iinknoteHandle) { kManagedObjectDestructorVma, &firmwareApi().managedObjectDestructor) && resolvePinned(iinkHandle, kRendererGetBackendSymbol, kRendererGetBackendVma, &firmwareApi().rendererGetBackend) + && resolvePinned(iinkHandle, kRendererGetViewTransformSymbol, + kRendererGetViewTransformVma, + &firmwareApi().rendererGetViewTransform) && resolvePinned(iinkHandle, kRendererRestrictToLayersSymbol, kRendererRestrictToLayersVma, &firmwareApi().rendererRestrictToLayers) && resolvePinned(iinkHandle, kPageControllerInputDispatcherSymbol, diff --git a/mods/custom-notebook-templates/src/hooks.cc b/mods/custom-notebook-templates/src/hooks.cc index 94d2365..08ad63f 100644 --- a/mods/custom-notebook-templates/src/hooks.cc +++ b/mods/custom-notebook-templates/src/hooks.cc @@ -9,6 +9,7 @@ #include "layers_state.h" #include "notebook_hook_services.h" #include "notebook_menu.h" +#include "notebook_sleep.h" #include "notebook_widget.h" #include "page_actions.h" #include "plugin_runtime.h" @@ -177,12 +178,17 @@ void _cnt_render_volume_hook(void* widget, void const* volume) { // caller reaches this symbol through the PLT, so running after the original // is the first hookable point where all three identities are coherent. firmwareApi().renderVolumeOriginal(widget, volume); - if (!hookState().notebookLifecycleHooksReady || !widget) + if (!widget) return; QObject* const object = reinterpret_cast(widget); if (!cnt::notebook_widget::isNotebookWidget(object)) return; + cnt::notebook_sleep::observeNotebookWidget( + pluginState().notebookSleep, + reinterpret_cast(widget)); + if (!hookState().notebookLifecycleHooksReady) + return; if (eraserState().sizeApisReady) { try { applyConfiguredEraserSizeForWidget(object, "notebook-open"); diff --git a/mods/custom-notebook-templates/src/layers_preview.cc b/mods/custom-notebook-templates/src/layers_preview.cc index b9d947c..07b09b9 100644 --- a/mods/custom-notebook-templates/src/layers_preview.cc +++ b/mods/custom-notebook-templates/src/layers_preview.cc @@ -354,6 +354,69 @@ class LayerPreviewRendererListener { bool* writerInvoked_; }; +class FullPageRendererListener { +public: + FullPageRendererListener( + FirmwareApi* firmware, + Pins const& pins, + SharedImagePainter const& painter, + SharedBackendImageDrawer const& drawer, + SharedRenderer const& liveRenderer, + void* backend, + bool* writerInvoked, + ExtentOpaque* renderedExtent, + bool* extentCaptured) + : firmware_(firmware), + pins_(pins), + painter_(painter), + drawer_(drawer), + liveRenderer_(liveRenderer), + backend_(backend), + writerInvoked_(writerInvoked), + renderedExtent_(renderedExtent), + extentCaptured_(extentCaptured) {} + + virtual ~FullPageRendererListener() {} + + virtual void exportImage( + SharedRenderer const& renderer, + void const* selection, + ExtentOpaque extent, + unsigned int flags, + std::string const& path) { + if (writerInvoked_) + *writerInvoked_ = false; + if (extentCaptured_) + *extentCaptured_ = false; + if (!firmware_->stockBackendImageDrawerExport || !drawer_ || !backend_) + return; + StockPreviewCallbackContext callback(pins_, drawer_.get(), backend_); + if (renderedExtent_) + *renderedExtent_ = extent; + if (extentCaptured_) + *extentCaptured_ = true; + if (writerInvoked_) + *writerInvoked_ = true; + try { + firmware_->stockBackendImageDrawerExport( + callback.bytes, renderer, selection, extent, flags, path); + } catch (...) { + trace("notebook-sleep: stock current-page writer threw"); + } + } + +private: + FirmwareApi* firmware_; + Pins pins_; + SharedImagePainter painter_; + SharedBackendImageDrawer drawer_; + SharedRenderer liveRenderer_; + void* backend_; + bool* writerInvoked_; + ExtentOpaque* renderedExtent_; + bool* extentCaptured_; +}; + static bool liveNeboControllerAndBackend( Dependencies const& dependencies, layers::LayerContext const& context, @@ -628,6 +691,159 @@ static bool generateLayerPreview( return true; } +static bool exportCurrentPageImageImpl( + Dependencies const& dependencies, + QObject* notebookWidget, + QImage* image, + ExtentOpaque* extent, + LivePageView* liveView, + QString* error) { + if (image) + *image = QImage(); + if (extent) + *extent = ExtentOpaque{0.0f, 0.0f, 0.0f, 0.0f}; + if (liveView) { + liveView->transform = RendererViewTransformOpaque{ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; + } + FirmwareApi& firmware = *dependencies.firmware; + Pins const& pins = dependencies.pins; + if (!image || !extent || !liveView || !notebookWidget + || !cnt::notebook_widget::isNotebookWidget(notebookWidget) + || !dependencies.runtime->previewApisReady + || !firmware.pageControllerExportToPng + || !firmware.rendererGetViewTransform + || !firmware.stockBackendImageDrawerExport + || !StockPreviewCallbackContext::supports(pins)) { + if (error) { + *error = QLatin1String( + "Notebook sleep page unavailable: export APIs are not ready."); + } + return false; + } + if (notebookWidget->property("_cnt_layer_preview_active").toBool()) { + if (error) { + *error = QLatin1String( + "Notebook sleep page unavailable: renderer is busy."); + } + return false; + } + LayerPreviewActiveGuard const previewActive(notebookWidget); + + layers::LayerContext context; + context.widgetObject = notebookWidget; + context.widget = notebookWidget; + context.editor = cnt::notebook_widget::notePadEditor(notebookWidget); + if (!context.editor) { + if (error) { + *error = QLatin1String( + "Notebook sleep page unavailable: editor is not ready."); + } + return false; + } + + SharedRenderer rendererKeepAlive; + void* pageController = nullptr; + void* backend = nullptr; + if (!liveNeboControllerAndBackend( + dependencies, + context, + &rendererKeepAlive, + &pageController, + &backend, + error)) { + return false; + } + + // The PNG callback returns only the content extent and normalizes that + // extent to the output bitmap. Capture the live renderer's mapping now, + // while its zoom, pan, and device pixel scales still describe the visible + // notebook. This call is read-only and does not touch the editor state. + try { + liveView->transform = firmware.rendererGetViewTransform( + rendererKeepAlive.get()); + } catch (...) { + if (error) { + *error = QLatin1String( + "Notebook sleep page unavailable: view transform failed."); + } + return false; + } + SharedImagePainter painter; + SharedBackendImageDrawer drawer; + if (!createLayerPreviewDrawer( + dependencies, context, &painter, &drawer, error)) { + return false; + } + + QString const path = QDir(QDir::tempPath()).filePath( + QLatin1String("cnt-notebook-sleep-") + + QString::number(getpid()) + QLatin1Char('-') + + QUuid::createUuid().toString().remove('{').remove('}') + + QLatin1String(".png")); + QFile::remove(path); + bool writerInvoked = false; + bool extentCaptured = false; + std::shared_ptr const proxy = + std::make_shared( + &firmware, + pins, + painter, + drawer, + rendererKeepAlive, + backend, + &writerInvoked, + extent, + &extentCaptured); + SharedRendererListener const opaqueProxy( + proxy, + reinterpret_cast(proxy.get())); + + bool exported = false; + try { + std::string const output = path.toUtf8().constData(); + exported = firmware.pageControllerExportToPng( + pageController, SharedBox(), output, opaqueProxy, 0); + } catch (...) { + exported = false; + } + + QFileInfo const generated(path); + if (!exported || !writerInvoked || !extentCaptured || !generated.isFile() + || generated.size() <= 0 + || generated.size() > pins.maximumLayerPreviewBytes) { + QFile::remove(path); + if (error) { + *error = QLatin1String( + "Notebook sleep page unavailable: renderer produced no valid PNG."); + } + return false; + } + QImageReader reader(path); + QSize const decodedSize = reader.size(); + if (!decodedSize.isValid() || decodedSize.width() > 4096 + || decodedSize.height() > 4096) { + QFile::remove(path); + if (error) { + *error = QLatin1String( + "Notebook sleep page unavailable: rendered size is invalid."); + } + return false; + } + QImage const decoded = reader.read(); + QFile::remove(path); + if (decoded.isNull()) { + if (error) { + *error = QLatin1String( + "Notebook sleep page unavailable: rendered PNG is unreadable."); + } + return false; + } + *image = decoded; + trace("notebook-sleep: current page exported with isolated renderer"); + return true; +} + static bool readLayerPreviewCard( Dependencies const& dependencies, layers::LayerState const& state, @@ -1243,6 +1459,17 @@ bool layerPreviewNeedsRefresh( return layerPreviewNeedsRefreshImpl(dependencies, state, id); } +bool exportCurrentPageImage( + Dependencies dependencies, + QObject* notebookWidget, + QImage* image, + ExtentOpaque* extent, + LivePageView* liveView, + QString* error) { + return exportCurrentPageImageImpl( + dependencies, notebookWidget, image, extent, liveView, error); +} + QPixmap layerPreview( Dependencies dependencies, layers::LayerState const& state, diff --git a/mods/custom-notebook-templates/src/layers_preview.h b/mods/custom-notebook-templates/src/layers_preview.h index f0d0164..c117b16 100644 --- a/mods/custom-notebook-templates/src/layers_preview.h +++ b/mods/custom-notebook-templates/src/layers_preview.h @@ -15,6 +15,7 @@ class QLabel; class QMenu; class QObject; +class QImage; class QPixmap; struct FirmwareApi; @@ -79,6 +80,13 @@ struct DeferredLayerPreviewRow { QPointer textLabel; }; +// Snapshot of the live editor's page-to-view mapping. It is captured before +// the isolated exporter runs so the sleep compositor can undo the exporter's +// bounding-box normalization without guessing the device DPI or zoom. +struct LivePageView { + RendererViewTransformOpaque transform; +}; + void showLayerError( Dependencies dependencies, void* widget, @@ -96,6 +104,17 @@ bool layerPreviewNeedsRefresh( layers::LayerState const& state, QString const& id); +// Export the live selection through Kobo's isolated renderer and return its +// exact MyScript page-space extent. Unlike layer cards, this does not restrict +// the renderer, persist a cache file, or mutate the live editor. +bool exportCurrentPageImage( + Dependencies dependencies, + QObject* notebookWidget, + QImage* image, + ExtentOpaque* extent, + LivePageView* liveView, + QString* error); + QPixmap layerPreview( Dependencies dependencies, layers::LayerState const& state, diff --git a/mods/custom-notebook-templates/src/notebook_hook_services.cc b/mods/custom-notebook-templates/src/notebook_hook_services.cc index 6e60c3d..18fb073 100644 --- a/mods/custom-notebook-templates/src/notebook_hook_services.cc +++ b/mods/custom-notebook-templates/src/notebook_hook_services.cc @@ -65,9 +65,8 @@ void routeParserImage( void* parser, void const* volume, QImage const& image) { - if (!coverState.hooksReady + if (!coverState.parserHookReady || !firmware.contentGetId - || !cover_cache::zipApisReady(coverState) || !firmware.iinknoteBase || callerAddress != firmware.iinknoteBase + thumbnailCallbackReturnVma) { @@ -80,26 +79,34 @@ void routeParserImage( QString const notebookPath = cover_cache::notebookPathFromContentId( firmware.contentGetId(volume)); double const pathResolveMs = fs_util::elapsedMs(timer); - QString type; - if (notebookPath.isEmpty() - || !cover_cache::cachedNotebookCoverType( - coverState, notebookPath, &type, pathResolveMs)) { + if (notebookPath.isEmpty()) { firmware.parserImageParsedOriginal(parser, volume, image); return; } - QImage const composed = cover_cache::composeCoverWithRenderedInk( - coverState, type, image); - if (composed.isNull()) { - firmware.parserImageParsedOriginal(parser, volume, image); + QString type; + if (coverState.hooksReady + && cover_cache::zipApisReady(coverState) + && cover_cache::cachedNotebookCoverType( + coverState, notebookPath, &type, pathResolveMs)) { + QImage const composed = cover_cache::composeCoverWithRenderedInk( + coverState, type, image); + if (!composed.isNull()) { + cover_cache::cacheRenderedCoverImage( + coverState, notebookPath, type, composed); + firmware.parserImageParsedOriginal(parser, volume, composed); + trace("covers: custom cover composed with rendered page ink"); + return; + } trace("covers: cover-and-ink composition failed; stock preview preserved"); - return; } + // Keep Kobo's own page-zero render for notebooks without a plugin cover. + // The empty type annotation intentionally distinguishes stock page zero + // from a composed custom cover in the shared persisted cache. cover_cache::cacheRenderedCoverImage( - coverState, notebookPath, type, composed); - firmware.parserImageParsedOriginal(parser, volume, composed); - trace("covers: custom cover composed with rendered page ink"); + coverState, notebookPath, QString(), image); + firmware.parserImageParsedOriginal(parser, volume, image); } void augmentNotebookGridCover( diff --git a/mods/custom-notebook-templates/src/notebook_sleep.cc b/mods/custom-notebook-templates/src/notebook_sleep.cc new file mode 100644 index 0000000..badefbb --- /dev/null +++ b/mods/custom-notebook-templates/src/notebook_sleep.cc @@ -0,0 +1,794 @@ +#include "notebook_sleep.h" + +#include "cover_cache.h" +#include "firmware_api.h" +#include "firmware_resolver.h" +#include "layers_preview.h" +#include "notebook_widget.h" +#include "notebooksleepsettingsreceiver.h" +#include "pages.h" +#include "plugin_runtime.h" +#include "plugin_state.h" +#include "settings.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +enum FirmwarePin : uintptr_t { + ShowSleepViewVma = 0xf3eb98, + ShowPowerOffViewVma = 0xf3ea54, + OnCoverLoadedVma = 0xf425b4, + UpdateCoverVma = 0xf430b0, + UpdateReadingStatusVma = 0xf42798, + SetInfoPanelVisibleVma = 0xf3a114, + SettingsPowerViewConstructorVma = 0x10455dc, + CheckBoxConstructorVma = 0x1065824, + DropDownConstructorVma = 0x106599c, + CheckBoxSetTextVma = 0x10653a0, + CheckBoxSetCheckedVma = 0x10653c0, + ItemSetLabelVma = 0x10652c0, + DropDownVma = 0x10653f8, + DropDownAddItemVma = 0x10de390, + DropDownSetCurrentIndexVma = 0x1113230, +}; + +enum LayoutPin { + SettingItemObjectBytes = 0x7c, + FallbackScreenWidth = 1404, + FallbackScreenHeight = 1872, + MaximumBackgroundPngBytes = 32 * 1024 * 1024, +}; + +template +void* functionPointer(Function function) { + union { + Function function; + void* pointer; + } value = {}; + value.function = function; + return value.pointer; +} + +template +bool installPinnedHook( + void* handle, + char const* symbol, + uintptr_t vma, + Function replacement, + Function* original) { + Function resolved = nullptr; + if (!resolvePinned(handle, symbol, vma, &resolved)) + return false; + *original = resolved; + void* const hooked = nh_dlhook( + handle, symbol, functionPointer(replacement)); + if (!pointerMatchesVma(hooked, vma)) + return false; + union { + void* pointer; + Function function; + } value = {}; + value.pointer = hooked; + *original = value.function; + return true; +} + +static bool activeNotebookWidget(QWidget* widget) { + if (!widget + || !cnt::notebook_widget::isNotebookWidget(widget) + || !widget->isVisible() + || widget->visibleRegion().isEmpty()) { + return false; + } + QWidget* const window = widget->window(); + return window && window->isVisible(); +} + +static QImage fitToPowerView(QImage const& source) { + if (source.isNull()) + return QImage(); + QSize const target(FallbackScreenWidth, FallbackScreenHeight); + if (source.size() == target) + return source.convertToFormat(QImage::Format_RGB32); + + QImage const scaled = source.scaled( + target, Qt::KeepAspectRatio, Qt::SmoothTransformation); + if (scaled.isNull()) + return QImage(); + QImage result(target, QImage::Format_RGB32); + result.fill(Qt::white); + QPainter painter(&result); + painter.drawImage( + (target.width() - scaled.width()) / 2, + (target.height() - scaled.height()) / 2, + scaled); + return result; +} + +static cnt::layers_preview::Dependencies previewDependencies() { + cnt::layers_preview::Dependencies const dependencies = { + &firmwareApi(), + &layerState(), + &coverState(), + layerToolRoutingOperations(), + layerPreviewPins() + }; + return dependencies; +} + +static QString condorVariantPath(QString const& basePath) { + if (basePath.endsWith(QLatin1String("_condor.png"), Qt::CaseInsensitive)) + return basePath; + if (!basePath.endsWith(QLatin1String(".png"), Qt::CaseInsensitive)) + return QString(); + QString path = basePath; + path.chop(4); + path.append(QLatin1String("_condor.png")); + return path; +} + +static void clearBackgroundCache( + cnt::notebook_sleep::RuntimeState& state) { + state.cachedBackgroundType.clear(); + state.cachedBackgroundPath.clear(); + state.cachedBackgroundImage = QImage(); + state.cachedBackgroundModifiedMs = -1; + state.cachedBackgroundSize = -1; +} + +static QImage activePageBackground( + cnt::notebook_sleep::RuntimeState& state, + QWidget* widget) { + FirmwareApi& firmware = firmwareApi(); + void* const backgroundWidget = widget + ? cnt::notebook_widget::notePadBackgroundWidget(widget) : nullptr; + if (!backgroundWidget || !firmware.backgroundType || !firmware.rendererMap) + return QImage(); + + QString const type = firmware.backgroundType(backgroundWidget); + if (type.isEmpty()) + return QImage(); + + // Background maps are initialized once per Nickel process. On the normal + // repeated-sleep path, avoid even a map lookup or resource probe. External + // papers retain the cheap mtime/size validation used by the other caches. + if (state.cachedBackgroundType == type + && !state.cachedBackgroundPath.isEmpty() + && !state.cachedBackgroundImage.isNull()) { + if (state.cachedBackgroundPath.startsWith(QLatin1String(":/"))) + return state.cachedBackgroundImage; + QFileInfo const cachedInfo(state.cachedBackgroundPath); + if (cachedInfo.isFile() + && cachedInfo.size() == state.cachedBackgroundSize + && cachedInfo.lastModified().toMSecsSinceEpoch() + == state.cachedBackgroundModifiedMs) { + return state.cachedBackgroundImage; + } + } + + QString const basePath = firmware.rendererMap->value(type); + if (basePath.isEmpty()) + return QImage(); + + QString path = condorVariantPath(basePath); + if (path.isEmpty() || !QFile::exists(path)) + path = basePath; + bool const immutableResource = path.startsWith(QLatin1String(":/")); + QFileInfo const info(path); + qint64 const size = immutableResource ? -1 : info.size(); + qint64 const modifiedMs = immutableResource + ? -1 : info.lastModified().toMSecsSinceEpoch(); + if (!QFile::exists(path) + || (!immutableResource + && (!info.isFile() || size <= 0 + || size > MaximumBackgroundPngBytes))) { + clearBackgroundCache(state); + cnt::trace("notebook-sleep: active template image unavailable"); + return QImage(); + } + + if (state.cachedBackgroundType == type + && state.cachedBackgroundPath == path + && !state.cachedBackgroundImage.isNull() + && (immutableResource + || (state.cachedBackgroundModifiedMs == modifiedMs + && state.cachedBackgroundSize == size))) { + return state.cachedBackgroundImage; + } + + QImageReader reader(path); + QSize const decodedSize = reader.size(); + if (decodedSize != QSize(FallbackScreenWidth, FallbackScreenHeight)) { + clearBackgroundCache(state); + cnt::trace("notebook-sleep: active template dimensions rejected"); + return QImage(); + } + QImage const decoded = reader.read(); + if (decoded.isNull()) { + clearBackgroundCache(state); + cnt::trace("notebook-sleep: active template decode failed"); + return QImage(); + } + + // The device-specific paper PNG can be a transparent line overlay. + // Flatten it once onto white and keep exactly one decoded template in + // memory; repeated sleeps on the same paper avoid file I/O and decoding. + QImage opaque(decodedSize, QImage::Format_RGB32); + opaque.fill(Qt::white); + QPainter backgroundPainter(&opaque); + backgroundPainter.drawImage(0, 0, decoded); + backgroundPainter.end(); + if (opaque.isNull()) { + clearBackgroundCache(state); + return QImage(); + } + + state.cachedBackgroundType = type; + state.cachedBackgroundPath = path; + state.cachedBackgroundImage = opaque; + state.cachedBackgroundModifiedMs = modifiedMs; + state.cachedBackgroundSize = size; + return state.cachedBackgroundImage; +} + +static QImage compositeActiveBackground( + cnt::notebook_sleep::RuntimeState& state, + QWidget* widget, + QImage const& renderedPage, + ExtentOpaque const& extent, + cnt::layers_preview::LivePageView const& liveView, + QPoint const& canvasOrigin) { + if (renderedPage.isNull()) + return QImage(); + if (!std::isfinite(extent.left) || !std::isfinite(extent.top) + || !std::isfinite(extent.right) || !std::isfinite(extent.bottom) + || extent.right <= extent.left || extent.bottom <= extent.top) { + cnt::trace("notebook-sleep: renderer extent rejected"); + return QImage(); + } + RendererViewTransformOpaque const& transform = liveView.transform; + if (!std::isfinite(transform.xx) || !std::isfinite(transform.xy) + || !std::isfinite(transform.x0) || !std::isfinite(transform.yx) + || !std::isfinite(transform.yy) || !std::isfinite(transform.y0)) { + cnt::trace("notebook-sleep: renderer view transform rejected"); + return QImage(); + } + + // QTransform's six-argument constructor stores the cross terms in the + // opposite textual order from atk::core::Transform. Spell out the + // conversion so the equations remain: + // + // screenX = xx*pageX + xy*pageY + x0 + canvasX + // screenY = yx*pageX + yy*pageY + y0 + canvasY + QTransform const pageToScreen( + transform.xx, + transform.yx, + transform.xy, + transform.yy, + transform.x0 + canvasOrigin.x(), + transform.y0 + canvasOrigin.y()); + if (!pageToScreen.isInvertible()) { + cnt::trace("notebook-sleep: renderer view transform is singular"); + return QImage(); + } + QRectF const pageBounds( + 0.0, 0.0, FallbackScreenWidth, FallbackScreenHeight); + + // The exporter maps its MyScript page-space extent into the complete PNG. + // Apply the live renderer's exact affine mapping to reverse that + // normalization. This preserves the live zoom, independent X/Y device + // scales, pan, and the canvas layout offset without a DPI approximation. + QRectF const modelExtent( + extent.left, + extent.top, + extent.right - extent.left, + extent.bottom - extent.top); + QRectF const pageExtentBounds = pageToScreen.mapRect(modelExtent); + if (!pageExtentBounds.intersects(pageBounds) + || pageExtentBounds.width() > FallbackScreenWidth * 4.0 + || pageExtentBounds.height() > FallbackScreenHeight * 4.0 + || pageExtentBounds.left() < -FallbackScreenWidth * 4.0 + || pageExtentBounds.top() < -FallbackScreenHeight * 4.0) { + cnt::trace("notebook-sleep: renderer extent outside page"); + return QImage(); + } + + QImage result = activePageBackground(state, widget); + if (result.isNull()) { + result = QImage( + QSize(FallbackScreenWidth, FallbackScreenHeight), + QImage::Format_RGB32); + result.fill(Qt::white); + } + QPainter painter(&result); + painter.setCompositionMode(QPainter::CompositionMode_Multiply); + if (renderedPage.size() != pageExtentBounds.size().toSize()) + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.setWorldTransform(pageToScreen); + painter.drawImage( + modelExtent, + renderedPage, + QRectF(renderedPage.rect())); + painter.end(); + cnt::trace("notebook-sleep: ink restored to renderer extent"); + return result; +} + +static QImage exportedCurrentPage( + cnt::notebook_sleep::RuntimeState& state, + QWidget* widget) { + QImage image; + ExtentOpaque extent = {0.0f, 0.0f, 0.0f, 0.0f}; + cnt::layers_preview::LivePageView liveView = {}; + QString error; + if (cnt::layers_preview::exportCurrentPageImage( + previewDependencies(), + widget, + &image, + &extent, + &liveView, + &error)) { + QWidget* canvas = nullptr; + void* const rawBackground = widget + ? cnt::notebook_widget::notePadBackgroundWidget(widget) : nullptr; + if (rawBackground) { + canvas = qobject_cast( + reinterpret_cast(rawBackground)); + } + QWidget* const window = widget ? widget->window() : nullptr; + if (!canvas || !window || canvas->window() != window) + canvas = widget; + QPoint const canvasOrigin = canvas && window + ? canvas->mapTo(window, QPoint(0, 0)) : QPoint(0, 0); + if (!state.extentTraceRecorded) { + state.extentTraceRecorded = true; + RendererViewTransformOpaque const& transform = liveView.transform; + cnt::trace( + QLatin1String("notebook-sleep: renderer bounds left=") + + QString::number(extent.left, 'f', 2) + + QLatin1String(" top=") + QString::number(extent.top, 'f', 2) + + QLatin1String(" right=") + QString::number(extent.right, 'f', 2) + + QLatin1String(" bottom=") + QString::number(extent.bottom, 'f', 2) + + QLatin1String(" png=") + QString::number(image.width()) + + QLatin1Char('x') + QString::number(image.height()) + + QLatin1String(" transform=[") + + QString::number(transform.xx, 'f', 4) + QLatin1Char(',') + + QString::number(transform.xy, 'f', 4) + QLatin1Char(',') + + QString::number(transform.x0, 'f', 2) + QLatin1Char(';') + + QString::number(transform.yx, 'f', 4) + QLatin1Char(',') + + QString::number(transform.yy, 'f', 4) + QLatin1Char(',') + + QString::number(transform.y0, 'f', 2) + + QLatin1String("] canvas=") + + QString::number(canvasOrigin.x()) + QLatin1Char(',') + + QString::number(canvasOrigin.y()) + + QLatin1Char(' ') + + QString::number(canvas ? canvas->width() : 0) + + QLatin1Char('x') + + QString::number(canvas ? canvas->height() : 0) + + QLatin1String(" unit=mm")); + } + return compositeActiveBackground( + state, widget, image, extent, liveView, canvasOrigin); + } + if (!error.isEmpty()) + cnt::trace(QLatin1String("notebook-sleep: isolated export unavailable: ") + error); + return QImage(); +} + +static QImage notebookCover( + cnt::notebook_sleep::RuntimeState& state, + QWidget* widget, + QString const& notebookPath) { + QString type; + if (cnt::cover_cache::zipApisReady(coverState()) + && cnt::cover_cache::cachedNotebookCoverType( + coverState(), notebookPath, &type, 0.0)) { + QImage image = cnt::cover_cache::cachedRenderedCoverImage( + coverState(), notebookPath, type); + if (image.isNull()) + image = cnt::cover_cache::cleanCustomCoverImage(coverState(), type); + if (!image.isNull()) + return image; + } + + QImage const stock = cnt::cover_cache::cachedRenderedCoverImage( + coverState(), notebookPath, QString()); + if (!stock.isNull()) + return stock; + + // When Kobo has not requested a thumbnail yet, page zero can still use + // the full-page exporter without changing parts. Other pages preserve the + // stock book cover until a page-zero thumbnail becomes available. + cnt::pages::PageContext context; + QString error; + if (cnt::pages::loadPageContext( + firmwareApi(), widget, maximumNotebookPages(), &context, &error) + && context.index == 0) { + return exportedCurrentPage(state, widget); + } + return QImage(); +} + +} // namespace + +NotebookSleepSettingsReceiver::NotebookSleepSettingsReceiver( + cnt::SettingsStore* settings, + QWidget* selectorRow, + cnt::NotebookSleepSettings const& initial, + QObject* parent) + : QObject(parent), + settings_(settings), + selectorRow_(selectorRow), + enabled_(initial.enabled), + mode_(initial.mode) {} + +void NotebookSleepSettingsReceiver::enabledChanged(bool enabled) { + enabled_ = enabled; + if (selectorRow_) + selectorRow_->setEnabled(enabled); + persist(); +} + +void NotebookSleepSettingsReceiver::modeChanged(int index) { + if (index != cnt::NotebookSleepCover + && index != cnt::NotebookSleepCurrentPage) { + return; + } + mode_ = static_cast(index); + persist(); +} + +void NotebookSleepSettingsReceiver::persist() { + if (settings_) + settings_->persistNotebookSleep(enabled_, mode_); +} + +namespace cnt { +namespace notebook_sleep { + +bool installHooks(RuntimeState& state) { + void* const handle = dlopen( + "libnickel.so.1.0.0", RTLD_LAZY | RTLD_NOLOAD); + if (!handle) { + trace("notebook-sleep: libnickel handle unavailable"); + return false; + } + + bool const core = + installPinnedHook( + handle, + "_ZN22N3PowerWorkflowManager13showSleepViewEv", + ShowSleepViewVma, + _cnt_show_sleep_view_hook, + &state.api.showSleepViewOriginal) + && installPinnedHook( + handle, + "_ZN22N3PowerWorkflowManager16showPowerOffViewEv", + ShowPowerOffViewVma, + _cnt_show_power_off_view_hook, + &state.api.showPowerOffViewOriginal) + && installPinnedHook( + handle, + "_ZN19PowerViewController13onCoverLoadedERK6QImage", + OnCoverLoadedVma, + _cnt_power_cover_loaded_hook, + &state.api.onCoverLoadedOriginal) + && installPinnedHook( + handle, + "_ZN19PowerViewController11updateCoverEv", + UpdateCoverVma, + _cnt_power_update_cover_hook, + &state.api.updateCoverOriginal) + && installPinnedHook( + handle, + "_ZN19PowerViewController19updateReadingStatusEv", + UpdateReadingStatusVma, + _cnt_power_update_reading_status_hook, + &state.api.updateReadingStatusOriginal) + && installPinnedHook( + handle, + "_ZN25FullScreenDragonPowerView19setInfoPanelVisibleEb", + SetInfoPanelVisibleVma, + _cnt_power_set_info_panel_visible_hook, + &state.api.setInfoPanelVisibleOriginal); + state.coreHooksReady = core; + if (core) + trace("notebook-sleep: guarded power workflow hooks installed"); + else + trace("notebook-sleep: power workflow hook validation failed; feature disabled"); + + bool const nativeSettingsApis = + resolvePinned(handle, "_ZN23SettingItemWithCheckBoxC1EP7QWidget", + CheckBoxConstructorVma, &state.api.checkBoxConstructor) + && resolvePinned(handle, "_ZN23SettingItemWithDropDownC1EP7QWidget", + DropDownConstructorVma, &state.api.dropDownConstructor) + && resolvePinned(handle, "_ZN23SettingItemWithCheckBox7setTextERK7QString", + CheckBoxSetTextVma, &state.api.checkBoxSetText) + && resolvePinned(handle, "_ZN23SettingItemWithCheckBox10setCheckedEb", + CheckBoxSetCheckedVma, &state.api.checkBoxSetChecked) + && resolvePinned(handle, "_ZN15SettingItemBase8setLabelERK7QString", + ItemSetLabelVma, &state.api.itemSetLabel) + && resolvePinned(handle, "_ZNK23SettingItemWithDropDown8dropDownEv", + DropDownVma, &state.api.dropDown) + && resolvePinned( + handle, + "_ZN24MultiSelectTouchDropDown7addItemERK7QStringRK8QVariantRK7QLocaleb", + DropDownAddItemVma, + &state.api.dropDownAddItem) + && resolvePinned(handle, "_ZN13TouchDropDown15setCurrentIndexEi", + DropDownSetCurrentIndexVma, &state.api.dropDownSetCurrentIndex); + if (core && nativeSettingsApis + && installPinnedHook( + handle, + "_ZN19N3SettingsPowerViewC1EP7QWidget", + SettingsPowerViewConstructorVma, + _cnt_settings_power_view_constructor_hook, + &state.api.settingsPowerViewConstructorOriginal)) { + state.settingsHookReady = true; + trace("notebook-sleep: native power-settings hook installed"); + } else { + trace("notebook-sleep: native power-settings hook unavailable"); + } + dlclose(handle); + return state.coreHooksReady; +} + +void observeNotebookWidget(RuntimeState& state, QWidget* widget) { + if (!widget || !notebook_widget::isNotebookWidget(widget)) + return; + state.activeNotebook = widget; + state.renderHookReady = true; + trace("notebook-sleep: live notebook observed"); +} + +void prepareNotebookImage(RuntimeState& state) { + state.pendingNotebookImage = false; + state.pendingImage = QImage(); + if (!state.coreHooksReady) + return; + + NotebookSleepSettings const configured = + settingsStore().configuredNotebookSleep(); + if (!configured.enabled) + return; + QWidget* const widget = state.activeNotebook.data(); + if (!state.renderHookReady || !activeNotebookWidget(widget)) { + trace("notebook-sleep: no visible notebook at power boundary; stock cover preserved"); + return; + } + + try { + QString const notebookPath = QFileInfo( + firmwareApi().widgetFilePath(widget)).absoluteFilePath(); + if (!notebookPath.startsWith(QLatin1String("/mnt/onboard/")) + || !notebookPath.endsWith( + QLatin1String(".nebo"), Qt::CaseInsensitive)) { + trace("notebook-sleep: active notebook path rejected; stock cover preserved"); + return; + } + QImage image = configured.mode == NotebookSleepCurrentPage + ? exportedCurrentPage(state, widget) + : notebookCover(state, widget, notebookPath); + image = fitToPowerView(image); + if (image.isNull()) { + trace("notebook-sleep: notebook image unavailable; stock cover preserved"); + return; + } + state.pendingImage = image; + state.pendingNotebookImage = true; + trace(configured.mode == NotebookSleepCurrentPage + ? "notebook-sleep: current page armed for power view" + : "notebook-sleep: notebook cover armed for power view"); + } catch (...) { + state.pendingImage = QImage(); + state.pendingNotebookImage = false; + trace("notebook-sleep: image preparation threw; stock cover preserved"); + } +} + +void augmentPowerSettings(RuntimeState& state, QWidget* view) { + if (!state.settingsHookReady || !view + || view->findChild( + QLatin1String("_cntShowNotebookOnSleep"))) { + return; + } + QWidget* const container = view->findChild( + QLatin1String("coverFullscreenOptions")); + QVBoxLayout* const layout = container + ? qobject_cast(container->layout()) : nullptr; + if (!container || !layout) { + trace("notebook-sleep: coverFullscreenLayout not found; settings rows skipped"); + return; + } + + void* toggleMemory = nullptr; + void* selectorMemory = nullptr; + bool toggleConstructed = false; + bool selectorConstructed = false; + try { + toggleMemory = ::operator new(SettingItemObjectBytes); + state.api.checkBoxConstructor(toggleMemory, container); + toggleConstructed = true; + selectorMemory = ::operator new(SettingItemObjectBytes); + state.api.dropDownConstructor(selectorMemory, container); + selectorConstructed = true; + } catch (...) { + if (toggleConstructed) + reinterpret_cast(toggleMemory)->deleteLater(); + else if (toggleMemory) + ::operator delete(toggleMemory); + if (selectorConstructed) + reinterpret_cast(selectorMemory)->deleteLater(); + else if (selectorMemory) + ::operator delete(selectorMemory); + trace("notebook-sleep: native settings row construction failed"); + return; + } + + QWidget* const toggle = reinterpret_cast(toggleMemory); + QWidget* const selector = reinterpret_cast(selectorMemory); + toggle->setObjectName(QLatin1String("_cntShowNotebookOnSleep")); + selector->setObjectName(QLatin1String("_cntNotebookSleepImage")); + // Match Ui_N3SettingsPowerView::retranslateUi exactly: the inherited + // SettingItemBase label owns the left column, while setText() labels only + // the compact checkbox in the right column. Putting the setting name in + // setText() centers the entire control instead of aligning it with Kobo's + // native power rows. + state.api.itemSetLabel( + toggle, QLatin1String("Show notebook on sleep:")); + state.api.checkBoxSetText(toggle, QLatin1String("On")); + state.api.itemSetLabel( + selector, QLatin1String("Notebook sleep image:")); + + void* const dropDown = state.api.dropDown(selector); + if (!dropDown) { + toggle->deleteLater(); + selector->deleteLater(); + trace("notebook-sleep: native selector missing; settings rows skipped"); + return; + } + QLocale const locale; + state.api.dropDownAddItem( + dropDown, + QLatin1String("Cover"), + QVariant(NotebookSleepCover), + locale, + false); + state.api.dropDownAddItem( + dropDown, + QLatin1String("Current page"), + QVariant(NotebookSleepCurrentPage), + locale, + false); + + NotebookSleepSettings const configured = + settingsStore().configuredNotebookSleep(); + state.api.checkBoxSetChecked(toggle, configured.enabled); + state.api.dropDownSetCurrentIndex(dropDown, configured.mode); + selector->setEnabled(configured.enabled); + + NotebookSleepSettingsReceiver* const receiver = + new NotebookSleepSettingsReceiver( + &settingsStore(), selector, configured, view); + bool const toggleConnected = QObject::connect( + toggle, + SIGNAL(toggled(bool)), + receiver, + SLOT(enabledChanged(bool))); + bool const selectorConnected = QObject::connect( + reinterpret_cast(dropDown), + SIGNAL(currentIndexChanged(int)), + receiver, + SLOT(modeChanged(int))); + if (!toggleConnected || !selectorConnected) { + toggle->deleteLater(); + selector->deleteLater(); + receiver->deleteLater(); + trace("notebook-sleep: native settings signal connection failed"); + return; + } + layout->addWidget(toggle); + layout->addWidget(selector); + trace("notebook-sleep: native power-settings rows added"); +} + +} // namespace notebook_sleep +} // namespace cnt + +extern "C" __attribute__((visibility("default"))) +void _cnt_show_sleep_view_hook(void* manager) { + cnt::notebook_sleep::RuntimeState& state = pluginState().notebookSleep; + cnt::notebook_sleep::prepareNotebookImage(state); + if (state.api.showSleepViewOriginal) + state.api.showSleepViewOriginal(manager); +} + +extern "C" __attribute__((visibility("default"))) +void _cnt_show_power_off_view_hook(void* manager) { + cnt::notebook_sleep::RuntimeState& state = pluginState().notebookSleep; + cnt::notebook_sleep::prepareNotebookImage(state); + if (state.api.showPowerOffViewOriginal) + state.api.showPowerOffViewOriginal(manager); +} + +extern "C" __attribute__((visibility("default"))) +void _cnt_power_cover_loaded_hook(void* controller, QImage const& image) { + cnt::notebook_sleep::RuntimeState& state = pluginState().notebookSleep; + if (!state.api.onCoverLoadedOriginal) + return; + if (state.pendingNotebookImage) + cnt::trace("notebook-sleep: asynchronous cover replaced"); + state.api.onCoverLoadedOriginal( + controller, + state.pendingNotebookImage ? state.pendingImage : image); +} + +extern "C" __attribute__((visibility("default"))) +void _cnt_power_update_cover_hook(void* controller) { + cnt::notebook_sleep::RuntimeState& state = pluginState().notebookSleep; + if (state.pendingNotebookImage + && !state.pendingImage.isNull() + && state.api.onCoverLoadedOriginal) { + // updateCover() normally connects imageReady to a QSlotObject which + // stores onCoverLoaded's direct member-function address. That path + // bypasses the symbol jump slot. Feed the prepared image through the + // stock slot here and do not start a later book-cover load which could + // overwrite it. + state.api.onCoverLoadedOriginal(controller, state.pendingImage); + cnt::trace("notebook-sleep: prepared image applied at updateCover"); + return; + } + if (state.api.updateCoverOriginal) + state.api.updateCoverOriginal(controller); +} + +extern "C" __attribute__((visibility("default"))) +void _cnt_power_update_reading_status_hook(void* controller) { + cnt::notebook_sleep::RuntimeState& state = pluginState().notebookSleep; + if (!state.pendingNotebookImage && state.api.updateReadingStatusOriginal) + state.api.updateReadingStatusOriginal(controller); +} + +extern "C" __attribute__((visibility("default"))) +void _cnt_power_set_info_panel_visible_hook(void* view, bool visible) { + cnt::notebook_sleep::RuntimeState& state = pluginState().notebookSleep; + if (state.api.setInfoPanelVisibleOriginal) { + state.api.setInfoPanelVisibleOriginal( + view, state.pendingNotebookImage ? false : visible); + } +} + +extern "C" __attribute__((visibility("default"))) +void _cnt_settings_power_view_constructor_hook(void* view, QWidget* parent) { + cnt::notebook_sleep::RuntimeState& state = pluginState().notebookSleep; + if (!state.api.settingsPowerViewConstructorOriginal) + return; + state.api.settingsPowerViewConstructorOriginal(view, parent); + cnt::notebook_sleep::augmentPowerSettings( + state, reinterpret_cast(view)); +} diff --git a/mods/custom-notebook-templates/src/notebook_sleep.h b/mods/custom-notebook-templates/src/notebook_sleep.h new file mode 100644 index 0000000..26ca0dd --- /dev/null +++ b/mods/custom-notebook-templates/src/notebook_sleep.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include +#include +#include + +class QLocale; +class QVariant; +class QWidget; + +namespace cnt { +namespace notebook_sleep { + +typedef void (*PowerWorkflowView)(void* manager); +typedef void (*PowerCoverLoaded)(void* controller, QImage const& image); +typedef void (*PowerUpdateCover)(void* controller); +typedef void (*PowerUpdateReadingStatus)(void* controller); +typedef void (*PowerSetInfoPanelVisible)(void* view, bool visible); +typedef void (*SettingsPowerViewConstructor)(void* view, QWidget* parent); +typedef void (*SettingCheckBoxConstructor)(void* item, QWidget* parent); +typedef void (*SettingDropDownConstructor)(void* item, QWidget* parent); +typedef void (*SettingCheckBoxSetText)(void* item, QString const& text); +typedef void (*SettingCheckBoxSetChecked)(void* item, bool checked); +typedef void (*SettingItemSetLabel)(void* item, QString const& label); +typedef void* (*SettingDropDown)(void const* item); +typedef void (*DropDownAddItem)( + void* dropDown, + QString const& text, + QVariant const& data, + QLocale const& locale, + bool selected); +typedef void (*DropDownSetCurrentIndex)(void* dropDown, int index); + +struct NativeApi { + PowerWorkflowView showSleepViewOriginal = nullptr; + PowerWorkflowView showPowerOffViewOriginal = nullptr; + PowerCoverLoaded onCoverLoadedOriginal = nullptr; + PowerUpdateCover updateCoverOriginal = nullptr; + PowerUpdateReadingStatus updateReadingStatusOriginal = nullptr; + PowerSetInfoPanelVisible setInfoPanelVisibleOriginal = nullptr; + SettingsPowerViewConstructor settingsPowerViewConstructorOriginal = nullptr; + SettingCheckBoxConstructor checkBoxConstructor = nullptr; + SettingDropDownConstructor dropDownConstructor = nullptr; + SettingCheckBoxSetText checkBoxSetText = nullptr; + SettingCheckBoxSetChecked checkBoxSetChecked = nullptr; + SettingItemSetLabel itemSetLabel = nullptr; + SettingDropDown dropDown = nullptr; + DropDownAddItem dropDownAddItem = nullptr; + DropDownSetCurrentIndex dropDownSetCurrentIndex = nullptr; +}; + +struct RuntimeState { + NativeApi api; + QPointer activeNotebook; + QImage pendingImage; + QString cachedBackgroundType; + QString cachedBackgroundPath; + QImage cachedBackgroundImage; + qint64 cachedBackgroundModifiedMs = -1; + qint64 cachedBackgroundSize = -1; + bool pendingNotebookImage = false; + bool coreHooksReady = false; + bool settingsHookReady = false; + bool renderHookReady = false; + bool extentTraceRecorded = false; +}; + +bool installHooks(RuntimeState& state); +void observeNotebookWidget(RuntimeState& state, QWidget* widget); +void prepareNotebookImage(RuntimeState& state); +void augmentPowerSettings(RuntimeState& state, QWidget* view); + +} // namespace notebook_sleep +} // namespace cnt + +extern "C" __attribute__((visibility("default"))) +void _cnt_show_sleep_view_hook(void* manager); + +extern "C" __attribute__((visibility("default"))) +void _cnt_show_power_off_view_hook(void* manager); + +extern "C" __attribute__((visibility("default"))) +void _cnt_power_cover_loaded_hook(void* controller, QImage const& image); + +extern "C" __attribute__((visibility("default"))) +void _cnt_power_update_cover_hook(void* controller); + +extern "C" __attribute__((visibility("default"))) +void _cnt_power_update_reading_status_hook(void* controller); + +extern "C" __attribute__((visibility("default"))) +void _cnt_power_set_info_panel_visible_hook(void* view, bool visible); + +extern "C" __attribute__((visibility("default"))) +void _cnt_settings_power_view_constructor_hook(void* view, QWidget* parent); diff --git a/mods/custom-notebook-templates/src/notebooksleepsettingsreceiver.h b/mods/custom-notebook-templates/src/notebooksleepsettingsreceiver.h new file mode 100644 index 0000000..16515a8 --- /dev/null +++ b/mods/custom-notebook-templates/src/notebooksleepsettingsreceiver.h @@ -0,0 +1,31 @@ +#pragma once + +#include "settings.h" + +#include +#include + +class QWidget; + +class NotebookSleepSettingsReceiver : public QObject { + Q_OBJECT + +public: + NotebookSleepSettingsReceiver( + cnt::SettingsStore* settings, + QWidget* selectorRow, + cnt::NotebookSleepSettings const& initial, + QObject* parent = nullptr); + +public slots: + void enabledChanged(bool enabled); + void modeChanged(int index); + +private: + void persist(); + + cnt::SettingsStore* settings_; + QPointer selectorRow_; + bool enabled_; + cnt::NotebookSleepImageMode mode_; +}; diff --git a/mods/custom-notebook-templates/src/plugin_entry.cc b/mods/custom-notebook-templates/src/plugin_entry.cc index eb55a75..f762322 100644 --- a/mods/custom-notebook-templates/src/plugin_entry.cc +++ b/mods/custom-notebook-templates/src/plugin_entry.cc @@ -4,6 +4,7 @@ #include "eraser_menu.h" #include "firmware_api.h" #include "firmware_resolver.h" +#include "notebook_sleep.h" #include "plugin_runtime.h" #include "plugin_state.h" #include "settings.h" @@ -125,7 +126,8 @@ static void installHookAfterNaturalLoad() { trace("layers: native action hook unavailable; feature disabled"); } - if (layerState().hooksReady || eraserState().sizeApisReady) { + if (layerState().hooksReady || eraserState().sizeApisReady + || pluginState().notebookSleep.coreHooksReady) { SetToolTheme resolvedTheme = nullptr; RenderVolume resolvedRender = nullptr; bool const lifecycleSymbols = @@ -166,8 +168,13 @@ static void installHookAfterNaturalLoad() { hookState().notebookLifecycleHooksReady = lifecycleSymbols && themeHookValid && renderHookValid; if (hookState().notebookLifecycleHooksReady) { - trace("layers: active-layer lifecycle hooks installed"); - trace("eraser-size: notebook lifecycle reapply hooks installed"); + if (layerState().hooksReady) + trace("layers: active-layer lifecycle hooks installed"); + if (eraserState().sizeApisReady) { + trace("eraser-size: notebook lifecycle reapply hooks installed"); + } + if (pluginState().notebookSleep.coreHooksReady) + trace("notebook-sleep: notebook-open observer hook installed"); } else { if (layerState().hooksReady) trace("layers: lifecycle hook validation failed; feature disabled"); @@ -191,20 +198,42 @@ static void installHookAfterNaturalLoad() { trace("layers: read-only archive persistence probe unavailable"); } + // The parser hook is also the stock notebook-cover source for the + // sleep screen, so keep it independent from custom cover PNGs and the + // optional archive scanner. Exact-caller checks in routeParserImage() + // leave every non-thumbnail ParserInterface signal untouched. + void* originalImageParsed = nullptr; + ParserImageParsed resolvedImageParsed = nullptr; + if (firmwareApi().contentGetId + && resolvePinned(handle, kParserImageParsedSymbol, + kParserImageParsedVma, &resolvedImageParsed)) { + firmwareApi().parserImageParsedOriginal = resolvedImageParsed; + ParserImageParsedAddress imageReplacement; + imageReplacement.function = _cnt_parser_image_parsed_hook; + originalImageParsed = nh_dlhook( + handle, + kParserImageParsedSymbol, + imageReplacement.pointer); + if (pointerMatchesVma( + originalImageParsed, kParserImageParsedVma)) { + firmwareApi().parserImageParsedOriginal = + reinterpret_cast(originalImageParsed); + coverState().parserHookReady = true; + trace("notebook-sleep: stock page-zero thumbnail hook installed"); + } else { + trace("notebook-sleep: parser-thumbnail hook validation failed"); + } + } + if (!coverState().customCovers.isEmpty() && cnt::cover_cache::loadZipApis(coverState())) { SetDialogTitle resolvedTitle = nullptr; - ParserImageParsed resolvedImageParsed = nullptr; bool const coverHookSymbols = resolvePinned(handle, kSetDialogTitleSymbol, - kSetDialogTitleVma, &resolvedTitle) - && resolvePinned(handle, kParserImageParsedSymbol, - kParserImageParsedVma, &resolvedImageParsed); + kSetDialogTitleVma, &resolvedTitle); void* originalTitle = nullptr; - void* originalImageParsed = nullptr; if (coverHookSymbols) { firmwareApi().setDialogTitleOriginal = resolvedTitle; - firmwareApi().parserImageParsedOriginal = resolvedImageParsed; SetDialogTitleAddress titleReplacement; titleReplacement.function = _cnt_set_dialog_title_hook; @@ -216,18 +245,6 @@ static void installHookAfterNaturalLoad() { firmwareApi().setDialogTitleOriginal = reinterpret_cast(originalTitle); } - - ParserImageParsedAddress imageReplacement; - imageReplacement.function = _cnt_parser_image_parsed_hook; - originalImageParsed = nh_dlhook( - handle, - kParserImageParsedSymbol, - imageReplacement.pointer); - if (pointerMatchesVma( - originalImageParsed, kParserImageParsedVma)) { - firmwareApi().parserImageParsedOriginal = - reinterpret_cast(originalImageParsed); - } } void* const nickelHandle = dlopen( @@ -278,8 +295,7 @@ static void installHookAfterNaturalLoad() { // (including notebook subfolders). if (pointerMatchesVma(original, kAddWidgetActionVma) && pointerMatchesVma(originalTitle, kSetDialogTitleVma) - && pointerMatchesVma( - originalImageParsed, kParserImageParsedVma)) { + && coverState().parserHookReady) { coverState().hooksReady = true; trace("covers: guarded menu, picker-title, and parser-thumbnail hooks installed"); } else { @@ -299,6 +315,7 @@ static void installHookAfterNaturalLoad() { static int initialize() { publishPluginState(new PluginState()); trace("NickelHook init entered"); + cnt::notebook_sleep::installHooks(pluginState().notebookSleep); cnt::visibility_hooks::install(); cnt::visibility::hideLegacyNotebookBackups(coverBackupRoot()); hookState().timer = new QTimer(QCoreApplication::instance()); diff --git a/mods/custom-notebook-templates/src/plugin_state.h b/mods/custom-notebook-templates/src/plugin_state.h index 5cf9bc7..7a62613 100644 --- a/mods/custom-notebook-templates/src/plugin_state.h +++ b/mods/custom-notebook-templates/src/plugin_state.h @@ -4,6 +4,7 @@ #include "eraser_menu.h" #include "firmware_api.h" #include "layers.h" +#include "notebook_sleep.h" #include "settings.h" #include "templates.h" #include "visibility.h" @@ -38,6 +39,7 @@ struct PluginState { cnt::layers::RuntimeState layers; cnt::eraser_menu::RuntimeState eraser; cnt::SettingsStore settings; + cnt::notebook_sleep::RuntimeState notebookSleep; cnt::visibility::RuntimeState visibility; HookRuntimeState hooks; }; diff --git a/mods/custom-notebook-templates/src/settings.cc b/mods/custom-notebook-templates/src/settings.cc index b73c4d3..342908e 100644 --- a/mods/custom-notebook-templates/src/settings.cc +++ b/mods/custom-notebook-templates/src/settings.cc @@ -53,13 +53,13 @@ void SettingsStore::rememberEraserSizeIndex(int index) { return; QMutexLocker locker(&mutex_); sizeIndex_ = index; - loaded_ = true; + eraserLoaded_ = true; } int SettingsStore::configuredEraserSizeIndex() { { QMutexLocker locker(&mutex_); - if (loaded_) + if (eraserLoaded_) return sizeIndex_; } @@ -97,9 +97,9 @@ int SettingsStore::configuredEraserSizeIndex() { { QMutexLocker locker(&mutex_); - if (!loaded_) { + if (!eraserLoaded_) { sizeIndex_ = loadedIndex; - loaded_ = true; + eraserLoaded_ = true; } loadedIndex = sizeIndex_; } @@ -139,6 +139,116 @@ bool SettingsStore::persistEraserSizeIndex(int index) { return true; } +void SettingsStore::rememberNotebookSleep( + NotebookSleepSettings const& settings) { + QMutexLocker locker(&mutex_); + notebookSleep_ = settings; + notebookSleepLoaded_ = true; +} + +NotebookSleepSettings SettingsStore::configuredNotebookSleep() { + { + QMutexLocker locker(&mutex_); + if (notebookSleepLoaded_) + return notebookSleep_; + } + + NotebookSleepSettings loaded; + bool validFile = false; + QString const settingsPath = QLatin1String( + "/mnt/onboard/.kobo/custom/templates/notebook-sleep.json"); + QFile file(settingsPath); + QFileInfo const info(file); + if (!info.exists()) { + trace("notebook-sleep: no saved setting; feature disabled by default"); + } else if (!info.isFile() || info.size() < 1 || info.size() > 4096) { + trace("notebook-sleep: saved setting has invalid size; preserving file and using default"); + } else if (!file.open(QIODevice::ReadOnly)) { + trace("notebook-sleep: saved setting unreadable; preserving file and using default"); + } else { + QJsonParseError parseError; + QJsonDocument const document = QJsonDocument::fromJson( + file.readAll(), &parseError); + if (parseError.error == QJsonParseError::NoError + && document.isObject()) { + QJsonObject const object = document.object(); + QJsonValue const enabled = object.value(QLatin1String("enabled")); + QString const mode = object.value( + QLatin1String("image")).toString(); + if (enabled.isBool() + && (mode == QLatin1String("cover") + || mode == QLatin1String("current-page"))) { + loaded.enabled = enabled.toBool(); + loaded.mode = mode == QLatin1String("current-page") + ? NotebookSleepCurrentPage : NotebookSleepCover; + validFile = true; + } + } + if (!validFile) { + trace("notebook-sleep: saved setting malformed; preserving file and using default"); + } + } + + { + QMutexLocker locker(&mutex_); + if (!notebookSleepLoaded_) { + notebookSleep_ = loaded; + notebookSleepLoaded_ = true; + } + loaded = notebookSleep_; + } + trace(QLatin1String("notebook-sleep: configured enabled=") + + (loaded.enabled ? QLatin1String("yes") : QLatin1String("no")) + + QLatin1String(" image=") + + (loaded.mode == NotebookSleepCurrentPage + ? QLatin1String("current-page") : QLatin1String("cover")) + + (validFile + ? QLatin1String(" restored") : QLatin1String(" default"))); + return loaded; +} + +bool SettingsStore::persistNotebookSleep( + bool enabled, + NotebookSleepImageMode mode) { + if (mode != NotebookSleepCover && mode != NotebookSleepCurrentPage) + return false; + + NotebookSleepSettings settings; + settings.enabled = enabled; + settings.mode = mode; + rememberNotebookSleep(settings); + + QDir root; + if (!root.mkpath(QLatin1String(kTemplateRoot))) { + trace("notebook-sleep: settings directory unavailable; selection kept in memory"); + return false; + } + QSaveFile file(QLatin1String( + "/mnt/onboard/.kobo/custom/templates/notebook-sleep.json")); + if (!file.open(QIODevice::WriteOnly)) { + trace("notebook-sleep: setting could not be opened; selection kept in memory"); + return false; + } + QJsonObject object; + object.insert(QLatin1String("enabled"), enabled); + object.insert( + QLatin1String("image"), + mode == NotebookSleepCurrentPage + ? QLatin1String("current-page") : QLatin1String("cover")); + QByteArray bytes = QJsonDocument(object).toJson(QJsonDocument::Compact); + bytes.append('\n'); + if (file.write(bytes) != bytes.size() || !file.commit()) { + trace("notebook-sleep: setting commit failed; selection kept in memory"); + return false; + } + trace(QLatin1String("notebook-sleep: selection persisted enabled=") + + (enabled ? QLatin1String("yes") : QLatin1String("no")) + + QLatin1String(" image=") + + (mode == NotebookSleepCurrentPage + ? QLatin1String("current-page") : QLatin1String("cover"))); + return true; +} + } // namespace cnt __attribute__((constructor(101))) static void traceLibraryLoad() { diff --git a/mods/custom-notebook-templates/src/settings.h b/mods/custom-notebook-templates/src/settings.h index 8f27619..ab15c64 100644 --- a/mods/custom-notebook-templates/src/settings.h +++ b/mods/custom-notebook-templates/src/settings.h @@ -10,17 +10,37 @@ void trace(char const* message, bool truncate = false); void trace(QString const& message); bool validEraserSizeIndex(int index); +enum NotebookSleepImageMode { + NotebookSleepCover = 0, + NotebookSleepCurrentPage = 1, +}; + +struct NotebookSleepSettings { + bool enabled; + NotebookSleepImageMode mode; + + NotebookSleepSettings() + : enabled(false), mode(NotebookSleepCover) {} +}; + class SettingsStore { public: int configuredEraserSizeIndex(); bool persistEraserSizeIndex(int index); + NotebookSleepSettings configuredNotebookSleep(); + bool persistNotebookSleep( + bool enabled, + NotebookSleepImageMode mode); private: void rememberEraserSizeIndex(int index); + void rememberNotebookSleep(NotebookSleepSettings const& settings); QMutex mutex_; - bool loaded_ = false; + bool eraserLoaded_ = false; int sizeIndex_ = 2; + bool notebookSleepLoaded_ = false; + NotebookSleepSettings notebookSleep_; }; } // namespace cnt diff --git a/scripts/investigate-notebook-sleep.py b/scripts/investigate-notebook-sleep.py new file mode 100644 index 0000000..cd3f642 --- /dev/null +++ b/scripts/investigate-notebook-sleep.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Replayable libnickel investigation for notebook sleep-screen routing.""" + +from __future__ import annotations + +import argparse +import json +import pathlib +from collections.abc import Iterable + +from bnremote_api import connect + + +REPOSITORY_ROOT = pathlib.Path(__file__).resolve().parents[1] +EXPECTED_FILE = ( + REPOSITORY_ROOT / "extracted/rootfs/usr/local/Kobo/libnickel.so.1.0.0" +).resolve() +EXPECTED_SHA256 = "577235f308b4dea1c150caf87a376ecf34c8ecab04aacee0806feddd9e268744" + +# Binary Ninja rebases this ELF by 0x10000. The runtime ELF VMAs pinned by the +# plugin are each address below minus 0x10000. +FUNCTIONS = { + 0xF4EB98: "N3PowerWorkflowManager::showSleepView", + 0xF4EA54: "N3PowerWorkflowManager::showPowerOffView", + 0xF525B4: "PowerViewController::onCoverLoaded", + 0xF52798: "PowerViewController::updateReadingStatus", + 0xF4A114: "FullScreenDragonPowerView::setInfoPanelVisible", + 0xF4A0D0: "FullScreenDragonPowerView::setImage", + 0xF51A74: "PowerViewController::PowerViewController", + 0xF52610: "PowerViewController::onCoverLoaded continuation 1", + 0xF526B8: "PowerViewController::onCoverLoaded continuation 2", + 0xF5306C: "PowerViewController::qt_metacall", + 0xF530B0: "PowerViewController::updateCover", + 0xF5398C: "PowerViewController::loadView", + 0xF53F10: "PowerViewController QImage slot-object dispatcher", +} + +NAME_PATTERNS = ( + "N3PowerWorkflowManager", + "PowerViewController", + "FullScreenDragonPowerView", +) + +RELEVANT_NAME_TERMS = ( + "loadView", + "onCoverLoaded", + "setImage", + "setInfoPanelVisible", + "showPowerOffView", + "showSleepView", + "updateCover", + "updateReadingStatus", +) + +PROOF_BYTES = { + # loadView reaches updateCover through its PLT entry. + 0xF53A9C: bytes.fromhex("204665f70cce"), + # updateCover loads and stores onCoverLoaded's direct member pointer in + # the QSlotObject used for the imageReady connection. + 0xF53430: bytes.fromhex( + "dff828250021dff8283510205af802203964fa635af80330b9647b64" + ), + # The QSlotObject dispatcher invokes that stored member pointer via r2. + 0xF53F46: bytes.fromhex( + "c8688968c4074bbf401002eb600014580a4644bf801862585968bd4690bc1047" + ), +} + +IL_TERMS = ( + "call", + "cover", + "image", + "power", + "reading", + "sleep", + "view", +) + + +def identity(function: object | None) -> dict[str, object] | None: + if function is None: + return None + return { + "start": hex(function.start), + "name": function.name, + "architecture": function.architecture, + } + + +def call_sites(snapshot: object) -> list[dict[str, object]]: + return [ + { + "call_address": hex(site.call_address), + "target": hex(site.target), + "target_function": identity(site.function), + "tail_call": site.tail_call, + "llil_operation": site.llil_operation, + } + for site in snapshot.calls.callees.items + ] + + +def relevant_il(view: object, address: int) -> list[dict[str, object]]: + function = view.get_function_at(address) + instructions = function.il("hlil").instructions( + page_size=500, + projection="summary", + fields=("operation", "expression_index", "address", "text"), + ).take(2_000) + selected = [] + for instruction in instructions: + text = instruction.text + lowered = text.lower() + if instruction.operation.is_call or any(term in lowered for term in IL_TERMS): + selected.append( + { + "address": ( + None if instruction.address is None else hex(instruction.address) + ), + "expression_index": instruction.expr_index, + "operation": instruction.operation.name, + "text": text, + } + ) + return selected + + +def xrefs(view: object, address: int) -> list[dict[str, object]]: + records = view.xrefs_to(address, kind="code", page_size=500).take(2_000) + return [ + { + "address": hex(record.address), + "kind": record.kind, + "architecture": record.architecture, + "function": identity(record.function), + "target": None if record.target is None else hex(record.target), + } + for record in records + ] + + +def named_functions(view: object, patterns: Iterable[str]) -> list[dict[str, object]]: + found: dict[int, dict[str, object]] = {} + for pattern in patterns: + query = ( + view.function_query() + .where(name_contains=pattern) + .select("start", "name", "architecture") + .page_size(100) + ) + for snapshot in query.fetch_all(max_items=500): + if snapshot.start < 0xF00000: + continue + if not any(term in snapshot.name for term in RELEVANT_NAME_TERMS): + continue + found[snapshot.start] = { + "start": hex(snapshot.start), + "name": snapshot.name, + "architecture": snapshot.architecture, + } + return [found[address] for address in sorted(found)] + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--target", + default=str(EXPECTED_FILE), + help="Exact BN Remote instance ID, filename, or full path", + ) + args = parser.parse_args() + + with connect(target=args.target) as bn: + session = bn.session() + view = bn.current_view() + status = view.wait_for_analysis(timeout=60, poll_interval=0.1) + + assert pathlib.Path(view.filename or "").resolve() == EXPECTED_FILE + assert session.filename == str(EXPECTED_FILE) + assert session.view_type == "ELF" + assert view.architecture == "armv7" + assert view.platform == "linux-armv7" + assert session.raw_fingerprint["file_sha256"] == EXPECTED_SHA256 + assert session.instance_id == bn.instance.instance_id + assert session.process_id == bn.instance.process_id + assert not view.capabilities + assert status.complete and not status.incomplete + + snapshots = view.fetch_functions( + addresses=tuple(FUNCTIONS), + fields=("signature", "calls", "il"), + projection="summary", + limits={"callers": 100, "callees": 200, "tail_calls": 50}, + ) + functions = {} + for snapshot in snapshots: + assert snapshot.start in FUNCTIONS + assert not snapshot.incomplete + functions[hex(snapshot.start)] = { + "expected": FUNCTIONS[snapshot.start], + "symbol": snapshot.name, + "architecture": snapshot.architecture, + "callees": call_sites(snapshot), + "relevant_hlil": relevant_il(view, snapshot.start), + } + + report = { + "session": { + "file": str(EXPECTED_FILE), + "sha256": EXPECTED_SHA256, + "instance_id": session.instance_id, + "process_id": session.process_id, + "view_type": session.view_type, + "architecture": view.architecture, + "platform": view.platform, + "analysis_generation": status.generation, + "capabilities": dict(view.capabilities), + }, + "named_power_functions": named_functions(view, NAME_PATTERNS), + "functions": functions, + "on_cover_loaded_xrefs": xrefs(view, 0xF525B4), + "proof_bytes": {}, + "conclusion": ( + "PowerViewController::updateCover stores onCoverLoaded as a direct " + "C++ member-function pointer in a QSlotObject. The slot dispatcher " + "branches through that stored pointer, bypassing onCoverLoaded's " + "PLT jump slot. PowerViewController::loadView does call updateCover " + "through a PLT jump slot immediately before updateReadingStatus. " + "Hook updateCover and pass the prepared notebook QImage directly to " + "stock onCoverLoaded; skip the asynchronous book-cover load only " + "while a notebook image is pending." + ), + } + for address, expected in PROOF_BYTES.items(): + memory = view.read(address, len(expected)) + assert memory.complete and memory.data == expected + report["proof_bytes"][hex(address)] = memory.data.hex() + + print(json.dumps(report, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/scripts/test-verify-binary-parity.py b/scripts/test-verify-binary-parity.py index 667cf8c..a1ea65d 100755 --- a/scripts/test-verify-binary-parity.py +++ b/scripts/test-verify-binary-parity.py @@ -4,6 +4,7 @@ from __future__ import annotations import importlib.util +import json import os import pathlib import stat @@ -53,6 +54,8 @@ def write_arm_shared_object(path: pathlib.Path) -> None: elif "--undefined-only" in sys.argv: print(" U dlopen@GLIBC_2.4") print(" w _ITM_registerTMCloneTable") + if candidate and os.environ.get("FAKE_IMPORT_DRIFT"): + print(" U intentional_import") """ @@ -99,7 +102,28 @@ def write_tool(self, name: str, content: str) -> pathlib.Path: path.chmod(path.stat().st_mode | stat.S_IXUSR) return path - def run_verifier(self, **environment: str) -> subprocess.CompletedProcess[str]: + def write_delta_manifest( + self, + exports: list[dict[str, str]] | None = None, + imports: list[dict[str, str]] | None = None, + ) -> pathlib.Path: + path = self.root / "expected-symbol-delta.json" + path.write_text( + json.dumps( + { + "candidate_only_exports": exports or [], + "candidate_only_imports": imports or [], + } + ), + encoding="utf-8", + ) + return path + + def run_verifier( + self, + *extra_args: str, + **environment: str, + ) -> subprocess.CompletedProcess[str]: env = os.environ.copy() env.update(environment) return subprocess.run( @@ -113,6 +137,7 @@ def run_verifier(self, **environment: str) -> subprocess.CompletedProcess[str]: "--objdump", str(self.objdump), "--skip-pinned-plugin-abi", + *extra_args, ], check=False, text=True, @@ -130,9 +155,44 @@ def test_section_size_drift_is_informational(self) -> None: def test_added_dynamic_export_fails(self) -> None: result = self.run_verifier(FAKE_EXPORT_DRIFT="1") self.assertEqual(result.returncode, 1, result.stdout + result.stderr) - self.assertIn("candidate only: T accidental_export", result.stderr) + self.assertIn("unapproved candidate only: T accidental_export", result.stderr) self.assertIn("Binary parity verification FAILED", result.stderr) + def test_exact_expected_symbol_delta_passes(self) -> None: + manifest = self.write_delta_manifest( + exports=[{"kind": "T", "name": "accidental_export"}], + imports=[{"kind": "U", "name": "intentional_import"}], + ) + result = self.run_verifier( + "--expected-symbol-delta", + str(manifest), + FAKE_EXPORT_DRIFT="1", + FAKE_IMPORT_DRIFT="1", + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("1 approved additions", result.stdout) + + def test_missing_expected_symbol_delta_fails(self) -> None: + manifest = self.write_delta_manifest( + exports=[{"kind": "T", "name": "accidental_export"}], + ) + result = self.run_verifier("--expected-symbol-delta", str(manifest)) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn( + "approved candidate addition missing: T accidental_export", + result.stderr, + ) + + def test_malformed_expected_symbol_delta_is_rejected(self) -> None: + manifest = self.root / "expected-symbol-delta.json" + manifest.write_text( + json.dumps({"candidate_only_exports": []}), + encoding="utf-8", + ) + result = self.run_verifier("--expected-symbol-delta", str(manifest)) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertIn("must contain exactly", result.stderr) + def test_dt_needed_order_change_fails(self) -> None: result = self.run_verifier(FAKE_NEEDED_DRIFT="1") self.assertEqual(result.returncode, 1, result.stdout + result.stderr) diff --git a/scripts/test-verify-globals-budget.py b/scripts/test-verify-globals-budget.py index dff7f09..c15e4ab 100755 --- a/scripts/test-verify-globals-budget.py +++ b/scripts/test-verify-globals-budget.py @@ -274,7 +274,7 @@ def test_repository_inventory_matches_phase_two_budget(self) -> None: self.assertEqual(VERIFIER.validation_errors(audit), []) self.assertEqual(audit.names("mutable"), frozenset()) self.assertEqual(audit.count("mutex"), 0) - self.assertEqual(audit.count("immutable"), 175) + self.assertEqual(audit.count("immutable"), 177) self.assertEqual(audit.count("function"), 0) self.assertEqual(audit.framework_globals, ("NickelHook",)) @@ -393,7 +393,7 @@ def test_cli_reports_verified_inventory(self) -> None: ) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertIn("mutable namespace statics: 0 (none)", result.stdout) - self.assertIn("immutable namespace statics: 175", result.stdout) + self.assertIn("immutable namespace statics: 177", result.stdout) self.assertIn("framework global descriptors: 1 (NickelHook)", result.stdout) self.assertIn( "promoted-TU mutable namespace objects: 2 (gPluginState, info)", diff --git a/scripts/verify-binary-parity.py b/scripts/verify-binary-parity.py index ed663f3..ea280e4 100755 --- a/scripts/verify-binary-parity.py +++ b/scripts/verify-binary-parity.py @@ -12,6 +12,7 @@ import argparse import collections import hashlib +import json import os import pathlib import re @@ -24,6 +25,10 @@ Symbol = Tuple[str, str] SymbolCounts = Counter[Symbol] +EXPECTED_DELTA_KEYS = frozenset(( + "candidate_only_exports", + "candidate_only_imports", +)) class VerificationError(RuntimeError): @@ -212,19 +217,84 @@ def expanded_difference(left: SymbolCounts, right: SymbolCounts) -> Iterable[Sym yield symbol +def parse_expected_symbols(value: object, key: str) -> SymbolCounts: + if not isinstance(value, list): + raise VerificationError(f"{key} must be a JSON list") + symbols: SymbolCounts = collections.Counter() + for index, entry in enumerate(value): + if not isinstance(entry, dict) or set(entry) != {"kind", "name"}: + raise VerificationError( + f"{key}[{index}] must contain exactly kind and name" + ) + kind = entry["kind"] + name = entry["name"] + if not isinstance(kind, str) or len(kind) != 1 or not kind.isalpha(): + raise VerificationError(f"{key}[{index}].kind is invalid") + if not isinstance(name, str) or not name or any(char.isspace() for char in name): + raise VerificationError(f"{key}[{index}].name is invalid") + symbol = (kind, name) + if symbols[symbol]: + raise VerificationError( + f"{key} contains duplicate symbol: {symbol_label(symbol)}" + ) + symbols[symbol] = 1 + return symbols + + +def load_expected_symbol_delta(path: pathlib.Path | None) -> tuple[SymbolCounts, SymbolCounts]: + if path is None: + return collections.Counter(), collections.Counter() + try: + document = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise VerificationError(f"cannot read expected symbol delta {path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise VerificationError( + f"expected symbol delta is not valid JSON: {path}: {exc}" + ) from exc + if not isinstance(document, dict) or set(document) != EXPECTED_DELTA_KEYS: + raise VerificationError( + "expected symbol delta must contain exactly " + "candidate_only_exports and candidate_only_imports" + ) + return ( + parse_expected_symbols( + document["candidate_only_exports"], "candidate_only_exports" + ), + parse_expected_symbols( + document["candidate_only_imports"], "candidate_only_imports" + ), + ) + + def report_symbol_difference( title: str, reference: SymbolCounts, candidate: SymbolCounts, + expected_candidate_only: SymbolCounts | None = None, ) -> bool: - if reference == candidate: - print(f"PASS {title}: {sum(reference.values())} entries") + expected = expected_candidate_only or collections.Counter() + reference_only = reference - candidate + candidate_only = candidate - reference + if not reference_only and candidate_only == expected: + suffix = "" + if expected: + suffix = f" + {sum(expected.values())} approved additions" + print(f"PASS {title}: {sum(reference.values())} reference entries{suffix}") return True print(f"FAIL {title}", file=sys.stderr) - for symbol in expanded_difference(reference, candidate): + for symbol in sorted(reference_only.elements(), key=lambda item: (item[1], item[0])): print(f" reference only: {symbol_label(symbol)}", file=sys.stderr) - for symbol in expanded_difference(candidate, reference): - print(f" candidate only: {symbol_label(symbol)}", file=sys.stderr) + for symbol in expanded_difference(expected, candidate_only): + print( + f" approved candidate addition missing: {symbol_label(symbol)}", + file=sys.stderr, + ) + for symbol in expanded_difference(candidate_only, expected): + print( + f" unapproved candidate only: {symbol_label(symbol)}", + file=sys.stderr, + ) return False @@ -316,6 +386,14 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.add_argument("--tool-prefix", help="cross-tool prefix, including trailing -") parser.add_argument("--nm", help="path/name of nm or llvm-nm") parser.add_argument("--objdump", help="path/name of objdump or llvm-objdump") + parser.add_argument( + "--expected-symbol-delta", + type=pathlib.Path, + help=( + "JSON manifest of exact candidate-only exports and imports; " + "reference-only symbols and unlisted additions still fail" + ), + ) parser.add_argument( "--skip-pinned-plugin-abi", action="store_true", @@ -342,6 +420,9 @@ def main(argv: Sequence[str] | None = None) -> int: candidate_exports = dynamic_symbols(nm, args.candidate, True) reference_imports = dynamic_symbols(nm, args.reference, False) candidate_imports = dynamic_symbols(nm, args.candidate, False) + expected_exports, expected_imports = load_expected_symbol_delta( + args.expected_symbol_delta + ) reference_needed = needed_libraries(objdump, args.reference) candidate_needed = needed_libraries(objdump, args.candidate) reference_sections = section_sizes(objdump, args.reference) @@ -349,10 +430,16 @@ def main(argv: Sequence[str] | None = None) -> int: checks = [ report_symbol_difference( - "dynamic defined exports", reference_exports, candidate_exports + "dynamic defined exports", + reference_exports, + candidate_exports, + expected_exports, ), report_symbol_difference( - "dynamic undefined imports", reference_imports, candidate_imports + "dynamic undefined imports", + reference_imports, + candidate_imports, + expected_imports, ), report_needed_difference(reference_needed, candidate_needed), ] diff --git a/scripts/verify-globals-budget.py b/scripts/verify-globals-budget.py index b47441e..2134f3d 100755 --- a/scripts/verify-globals-budget.py +++ b/scripts/verify-globals-budget.py @@ -33,7 +33,7 @@ EXPECTED_MUTABLE = frozenset() EXPECTED_MUTEXES = frozenset() -EXPECTED_IMMUTABLE_COUNT = 175 +EXPECTED_IMMUTABLE_COUNT = 177 EXPECTED_FUNCTION_COUNT = 0 EXPECTED_FRAMEWORK_GLOBALS = ("NickelHook",) EXPECTED_PROMOTED_MUTABLE = frozenset(("gPluginState", "info")) diff --git a/scripts/verify-layer-abi.py b/scripts/verify-layer-abi.py index 8a94018..f5c09a5 100644 --- a/scripts/verify-layer-abi.py +++ b/scripts/verify-layer-abi.py @@ -25,9 +25,11 @@ "_ZNK8myscript8document13LayerIterator7isAtEndEv": 0x4B3E88, "_ZN8myscript6engine13ManagedObjectD1Ev": 0x3E5390, "_ZN8myscript4iink12RendererImpl10getBackendEv": 0x49E318, + "_ZNK8myscript4iink12RendererImpl16getViewTransformEv": 0x49DF8C, "_ZN3atk4core8Renderer16restrictToLayersERKSt6vectorISsSaISsEE": 0xA57714, "_ZN3snt14PageController11exportToPNGESt10shared_ptrIN3atk4core3BoxEERKSsS1_INS3_16RendererListenerEEj": 0x5130C4, "_ZNSt12__shared_ptrIN8myscript4iink18BackendImageDrawerELN9__gnu_cxx12_Lock_policyE2EEC2ISaIS2_EIRSt10shared_ptrINS1_10EngineImplEERS8_INS1_13IImagePainterEERS8_INS1_17ConfigurationImplEEEEESt19_Sp_make_shared_tagRKT_DpOT0_": 0x443EAC, + "_ZN8myscript4iink18BackendImageDrawer9drawImageEN3atk4core4PageESt10shared_ptrINS3_8RendererEEPNS0_8IBackendENS3_6ExtentENS3_9SelectionESs": 0x3E7B8C, "_ZN3snt14PageController19setRendererListenerESt10shared_ptrIN3atk4core16RendererListenerEE": 0x505498, "_ZNK3snt14PageController15inputDispatcherEv": 0x501960, "_ZNK3snt23PlatformInputDispatcher14getCurrentToolEv": 0x5BC80C, @@ -296,6 +298,16 @@ "97ed000a5b46d7ed010a504697ed021a07f11802d7ed031a" "2146cdf8049000956cf729fd" ), + # BackendImageDrawer treats atk::core::Extent as left/top/right/bottom. + # Its default image-size path subtracts the first coordinate pair from the + # second, converts the resulting millimetres through the configured image + # resolution, and rounds those two differences to pixel dimensions. This + # prevents the sleep compositor from ever interpreting right/bottom as + # width/height again. + 0x3E80F2: bytes.fromhex( + "dfedc59a30ee6a0a95ed069a38ee4b8a80ee290a20ee090a6af68aea" + "fdeec08a88ee290a29ee000a6af682ead9f81810bdeec09a" + ), } EXPECTED_IINKNOTE = { @@ -460,6 +472,21 @@ "_ZNK7Content5getIdEv": 0x953D84, "_ZN15FeatureSettings18excludeSyncFoldersEv": 0xA04650, "_ZN13VolumeManager20removeCommonBookDataERK6DeviceR6Volumeb": 0xA6EC74, + "_ZN22N3PowerWorkflowManager13showSleepViewEv": 0xF3EB98, + "_ZN22N3PowerWorkflowManager16showPowerOffViewEv": 0xF3EA54, + "_ZN19PowerViewController13onCoverLoadedERK6QImage": 0xF425B4, + "_ZN19PowerViewController11updateCoverEv": 0xF430B0, + "_ZN19PowerViewController19updateReadingStatusEv": 0xF42798, + "_ZN25FullScreenDragonPowerView19setInfoPanelVisibleEb": 0xF3A114, + "_ZN19N3SettingsPowerViewC1EP7QWidget": 0x10455DC, + "_ZN23SettingItemWithCheckBoxC1EP7QWidget": 0x1065824, + "_ZN23SettingItemWithDropDownC1EP7QWidget": 0x106599C, + "_ZN23SettingItemWithCheckBox7setTextERK7QString": 0x10653A0, + "_ZN23SettingItemWithCheckBox10setCheckedEb": 0x10653C0, + "_ZN15SettingItemBase8setLabelERK7QString": 0x10652C0, + "_ZNK23SettingItemWithDropDown8dropDownEv": 0x10653F8, + "_ZN24MultiSelectTouchDropDown7addItemERK7QStringRK8QVariantRK7QLocaleb": 0x10DE390, + "_ZN13TouchDropDown15setCurrentIndexEi": 0x1113230, } EXPECTED_NICKEL_SIZES = { @@ -475,9 +502,48 @@ EXPECTED_NICKEL_JUMP_SLOTS = { "_ZN13VolumeManager20removeCommonBookDataERK6DeviceR6Volumeb": 0x16B0470, "_ZN15FeatureSettings18excludeSyncFoldersEv": 0x16BB1F0, + "_ZN22N3PowerWorkflowManager16showPowerOffViewEv": 0x16AC9D8, + "_ZN19N3SettingsPowerViewC1EP7QWidget": 0x16AEF90, + "_ZN19PowerViewController13onCoverLoadedERK6QImage": 0x16B1C38, + "_ZN19PowerViewController11updateCoverEv": 0x16BCB5C, + "_ZN22N3PowerWorkflowManager13showSleepViewEv": 0x16B5698, + "_ZN19PowerViewController19updateReadingStatusEv": 0x16BB524, + "_ZN25FullScreenDragonPowerView19setInfoPanelVisibleEb": 0x16BD7B8, } PINNED_NICKEL_INSTRUCTIONS = { + # loadView calls updateCover through its PLT entry immediately before the + # stock reading-status update. This is the reliable notebook-image seam: + # the hook can apply the captured QImage and keep the stale book status + # suppressed for the following call. + 0xF43A9C: bytes.fromhex("204665f70cce"), + # updateCover stores onCoverLoaded as a direct C++ member-function pointer + # in a QSlotObject, and that dispatcher ultimately branches through r2. + # It therefore bypasses onCoverLoaded's otherwise valid PLT jump slot. + 0xF43430: bytes.fromhex( + "dff828250021dff8283510205af802203964fa635af80330b9647b64" + ), + 0xF43F46: bytes.fromhex( + "c8688968c4074bbf401002eb600014580a4644bf801862585968bd4690bc1047" + ), + # PowerViewController's generated metacall dispatch loads the QImage + # reference from Qt's argv array, restores the frame, and tail-branches + # through onCoverLoaded's PLT entry. Keep that fallback hook pinned, but + # updateCover's actual imageReady connection uses the direct QSlotObject + # path proved above, so notebook injection happens at updateCover instead. + # Binary Ninja address is ELF VMA +0x10000. + 0xF4304A: bytes.fromhex("5968bd46b0bc43f7309c"), + # Native power checkboxes are two-column SettingItem rows. retranslateUi + # first sends the full setting name to SettingItemBase::setLabel, then + # sends only "On" to SettingItemWithCheckBox::setText. The plugin mirrors + # this exact split so its checkbox aligns with the three stock rows. + # Binary Ninja address is ELF VMA +0x10000. + 0x1046758: bytes.fromhex( + "5ff624ce2146304635f65ecc78680368" + ), + 0x104678E: bytes.fromhex( + "5ff60ace214630464ef6b2cb78680368" + ), # Library asset visibility. FeatureSettings reads the user's existing # ExcludeSyncFolders value; findFilesToParse reaches it through this exact # PLT/GOT call, splits the result on a one-byte separator, and later wraps