diff --git a/README.md b/README.md index 721aa69..c773b01 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ targets include Raspberry Pi OS on the Raspberry Pi 5 and Sailfish OS. | Path | Purpose | | --- | --- | | `targets/raspberry-pi-os` | Raspberry Pi OS (Bookworm+ desktop, labwc/Wayland) SDL2 target: platform sources, build script, and target docs. | -| `targets/sailfish-os` | Sailfish OS 5.1 target: its own platform sources, host code generation, portable CMake project, and i486 RPM packaging. | +| `targets/sailfish-os` | Sailfish OS 5.1 target: its own platform sources, host code generation, portable CMake project, and i486, armv7hl, and aarch64 RPM packaging. | ## Quick Start diff --git a/targets/sailfish-os/CMakeLists.txt b/targets/sailfish-os/CMakeLists.txt index 5c000b2..25abaa3 100644 --- a/targets/sailfish-os/CMakeLists.txt +++ b/targets/sailfish-os/CMakeLists.txt @@ -10,6 +10,10 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(PkgConfig REQUIRED) pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2) pkg_check_modules(CURL REQUIRED IMPORTED_TARGET libcurl) +# Harbour allows libgio/libglib/libgobject and does not allow libmaliit-glib. +# The system keyboard uses those GIO libraries to speak Maliit's D-Bus API. +# See targets/sailfish-os/README.md. +pkg_check_modules(SAILFISH_GLIB REQUIRED IMPORTED_TARGET glib-2.0 gobject-2.0 gio-2.0) include(${CMAKE_CURRENT_SOURCE_DIR}/sources.cmake) add_executable(${GEA_PACKAGE_NAME} ${GEA_C_SOURCES} ${GEA_CXX_SOURCES}) @@ -20,6 +24,7 @@ target_compile_options(${GEA_PACKAGE_NAME} PRIVATE target_compile_definitions(${GEA_PACKAGE_NAME} PRIVATE GEA_EMBEDDED_HAS_GENERATED_FONTS=1 GEA_EMBEDDED_TTF_RUNTIME_FONTS=1 + GEA_EMBEDDED_ENABLE_VIRTUAL_KEYBOARD=0 $<$:GEA_EMBEDDED_GIF_C_API> GEA_SAILFISH_APP_ID="${GEA_APP_ID}" GEA_SAILFISH_APP_TITLE="${GEA_APP_TITLE}" @@ -27,7 +32,15 @@ target_compile_definitions(${GEA_PACKAGE_NAME} PRIVATE GEA_SAILFISH_APPLICATION_NAME="${GEA_SAILFISH_APPLICATION_NAME}") set_source_files_properties(${CMAKE_CURRENT_SOURCE_DIR}/platform/main/sailfish_main.cpp PROPERTIES COMPILE_DEFINITIONS GEA_SAILFISH_DEFAULT_DPR=${GEA_SAILFISH_DEFAULT_DPR}) -target_link_libraries(${GEA_PACKAGE_NAME} PRIVATE PkgConfig::SDL2 PkgConfig::CURL pthread m rt) +target_link_libraries(${GEA_PACKAGE_NAME} PRIVATE PkgConfig::SDL2 PkgConfig::CURL PkgConfig::SAILFISH_GLIB pthread m rt) +set(GEA_APP_LIBRARY_INDEX 0) +foreach(GEA_APP_LIBRARY ${GEA_APP_LIBRARIES}) + # foo-bar and foo.bar both become foo_bar; the index keeps each imported target distinct. + string(MAKE_C_IDENTIFIER "gea_app_${GEA_APP_LIBRARY_INDEX}_${GEA_APP_LIBRARY}" GEA_APP_LIBRARY_TARGET) + pkg_check_modules(${GEA_APP_LIBRARY_TARGET} REQUIRED IMPORTED_TARGET ${GEA_APP_LIBRARY}) + target_link_libraries(${GEA_PACKAGE_NAME} PRIVATE PkgConfig::${GEA_APP_LIBRARY_TARGET}) + math(EXPR GEA_APP_LIBRARY_INDEX "${GEA_APP_LIBRARY_INDEX} + 1") +endforeach() target_link_options(${GEA_PACKAGE_NAME} PRIVATE -Wl,--export-dynamic) install(TARGETS ${GEA_PACKAGE_NAME} RUNTIME DESTINATION bin) diff --git a/targets/sailfish-os/README.md b/targets/sailfish-os/README.md index 1d985cf..365f6b6 100644 --- a/targets/sailfish-os/README.md +++ b/targets/sailfish-os/README.md @@ -3,27 +3,61 @@ Builds a Gea JSX/CSS app into a local Sailfish OS RPM. The host runs Vite and geatsc; `sfdk` compiles the staged C/C++ sources for Sailfish OS 5.1.0.11. -Requirements: Node.js 20.19+, `npm install` in this Linux repository and in -the app's workspace, a checkout of `geastack/core`, and Sailfish SDK with the -selected build target. The default core checkout is `../core` relative to this -Linux repository. Pass `--core-repo` (Bash) or `-CoreRepository` (PowerShell) -for another location. - -Code generation uses this repository's npm core, compiler, and plugin. The -source manifest and native framework come from the core checkout. Preparation -checks that the npm core and checkout package versions match and fails before -code generation if they differ. Use `npm ci` for the locked tool dependencies -and a matching core checkout. Local core edits are still included in the build. - -From the Linux repository on Linux: +Supported `sfdk` targets are `SailfishOS-5.1.0.11-i486` (emulator), +`SailfishOS-5.1.0.11-armv7hl`, and `SailfishOS-5.1.0.11-aarch64`. Pass the +matching architecture to the build script. Publish the RPM that matches the +device; the i486 package is only for the emulator. + +Requirements: Node.js 20.19+, `npm ci` in the app (so the locked `@geastack/*` +packages are installed), and the Sailfish SDK with the selected build target. +A separate `geastack/core` checkout is not required. Pass `--core-repo` (Bash) +or `-CoreRepository` (PowerShell) to build the framework from a source checkout +instead. That checkout's `core`, `host`, `engine`, `elements`, and `geaos` +package versions must match the app's installed packages; the error names the +package that is missing or different. + +The app's `node_modules/@geastack/*` packages are used for code generation and +for the staged framework. If the app has not installed one of those packages, +preparation falls back to this Linux repository's `node_modules` so target +development still works. Do not patch those installed packages. + +From the Linux repository, or from an app that depends on `@geastack/linux`: ```sh -./targets/sailfish-os/build-sailfish-os.sh ../examples/apps/tic-tac-toe --arch i486 +./targets/sailfish-os/build-sailfish-os.sh ../examples/apps/tic-tac-toe --arch aarch64 ``` +When `@geastack/linux` is installed in the app, the same script lives at +`node_modules/@geastack/linux/targets/sailfish-os/build-sailfish-os.sh`. + The script finds `sfdk` on `PATH`. Pass `--sfdk /path/to/SailfishOS/bin/sfdk` if it is elsewhere. `--prepare-only` runs code generation and stages the -portable project without invoking `sfdk`. +portable project without invoking `sfdk`. `--clean` removes the staged RPM and +CMake outputs, including object files, and then builds. + +Generated sources and the staged project are written under the app, at +`/.gea-sailfish/`. Add that directory to the app's ignore rules. The RPM +is `/.gea-sailfish/build/-/project/RPMS/`. + +Before each `sfdk build`, empty or non-ELF `.o` files left by an interrupted +SDK engine are deleted. `sfdk` would otherwise treat a zero-byte object as up +to date and fail the link with undefined references. If `sfdk` fails, the +script exits with `sfdk`'s status and prints the last error line from the log. + +From the Linux repository on Windows: + +```powershell +./targets/sailfish-os/build-sailfish-os.ps1 -AppDirectory ../examples/apps/tic-tac-toe -Architecture aarch64 +``` + +Use `-SdkRoot` when Sailfish SDK is installed outside `C:\SailfishOS`. +`-PrepareOnly` stops after code generation. `-Clean` clears the staged build +before compiling. + +Install the RPM on a device with `sfdk` or by copying it to the phone and using +`pkcon install-local`. The SDK build engine must be running for the Windows +script. On this host, invoking `sfdk.exe` inside MSYS2 fails to identify its +Docker engine, while the PowerShell invocation works. The app's `gea.icons` entry supplies the icon source. Preparation generates 86, 108, 128, and 172 pixel PNGs for Harbour. For sandbox permissions, set @@ -35,25 +69,77 @@ Set only permissions the app actually needs, such as `Audio` for sound and `"permissions": ["Audio", "Internet"]`. An empty list does not allow networking; permissions are not inferred from app source code. -From the Linux repository on Windows: - -```powershell -./targets/sailfish-os/build-sailfish-os.ps1 -AppDirectory ../examples/apps/tic-tac-toe -Architecture i486 +## App native sources + +Sailfish-only C and C++ belong in the app and are declared in `package.json`. +Desktop, Apple, and Windows sources are not picked up automatically. The +system-keyboard bridge is part of this target, not an app source. + +```json +{ + "gea": { + "sailfish": { + "nativeSources": ["native/host.cpp", "native/host.h"], + "includePaths": ["native/vendor"], + "libraries": ["openssl"] + } + } +} ``` -Use `-SdkRoot` when Sailfish SDK is installed outside `C:\SailfishOS`. - -The RPM is written to -`targets/sailfish-os/build/-/project/RPMS/`. -Use `-PrepareOnly` in PowerShell for code generation without invoking `sfdk`. -Before submitting a device package to Harbour, run `sfdk check` on its RPM. -The `i486` build is for the emulator; build for the architecture of the target -device before publishing. - -The Windows script invokes the installed `sfdk.exe` with session handling off. -The SDK build engine must be running. On this host, invoking `sfdk.exe` inside -MSYS2 fails to identify its Docker engine, while the PowerShell invocation -works. MSYS2 remains installed for the SDK's other command-line workflows. +`nativeSources` lists app-relative C, C++, and header files. Each compiled +file's directory is added to the include path, so `native/host.cpp` can +`#include "host.h"` when the header sits next to it. `includePaths` copies +extra header or vendor directories into the staged project. `libraries` are +pkg-config module names: each one is linked and added to the RPM +`BuildRequires` as `pkgconfig()`. Runtime library dependencies are the +ones rpm records from the linked binary. + +## Text input + +On a Sailfish device the system keyboard is Maliit. Focusing an `` +shows it; blurring, or dismissing the keyboard, hides it. `type="password"` +is sent as Maliit's `hiddenText` flag with prediction turned off. Commit text, +Backspace, and Enter use the same helpers as a hardware keyboard. + +`libmaliit-glib` is not in Harbour's allowed library list +([Allowed APIs](https://docs.sailfishos.org/Develop/Apps/Harbour/Allowed_APIs/)). +The bridge uses GIO (`libgio-2.0.so.0`, `libglib-2.0.so.0`, `libgobject-2.0.so.0`), +which is allowed. It reads the server address from the session bus +(`org.maliit.server`, `/org/maliit/server/address`, property `address`) and +then calls `com.meego.inputmethod.uiserver1` on that peer connection. The Gea +on-screen keyboard is not the Sailfish default +(`GEA_EMBEDDED_ENABLE_VIRTUAL_KEYBOARD=0`). The emulator and a hardware +keyboard still deliver `SDL_TEXTINPUT`. If Maliit is absent, that is logged +and SDL input continues. + +Text is inserted as UTF-8. Backspace deletes the last Unicode code point +(trailing `10xxxxxx` bytes, then the lead byte), so `ç`, `ğ`, `ı`, `İ`, `ö`, +`ş`, and `ü` round-trip. A grapheme made of several code points is not +collapsed. ASCII input is unchanged. + +When the keyboard shrinks the window, the focused input is scrolled into view +after the new layout. + +## Pointer coordinates + +Hit testing uses canvas pixels. One function, `sailfish_window_to_canvas()`, +converts window points to those pixels for touch, mouse, and the wheel pointer: + +- Window points are `SDL_GetWindowSize`. Finger events are normalized across + that window and converted to window points first. +- Drawable pixels are `SDL_GetRendererOutputSize`. The window is created + without `SDL_WINDOW_ALLOW_HIGHDPI`, so the drawable matches the window. + Multiplying by the drawable size as well was the DPR 2 miss: the canvas + already uses `gea.sailfish.devicePixelRatio`, and the high-DPI flag scaled + the touch a second time. +- CSS pixels are canvas pixels divided by that DPR. The framework applies DPR. + Pointer code does not divide by it again. + +The same mapping is used after resize, including a keyboard resize. The +startup log prints window, drawable, and canvas sizes. + +## Runtime The Sailfish target owns its platform sources in `main/` and `include/`. Its SDL2 backend starts fullscreen with a scale of 1. Platform symbols and @@ -77,11 +163,35 @@ window then provides a 360x763.5 CSS viewport. The same setting is used for font generation and the executable's default DPR; `GEA_SAILFISH_DPR` overrides it at runtime. This is useful for phone layouts designed around a 360 CSS pixel width. -Fonts declared with `@font-face` are embedded as TTF files and rasterized at -runtime. Character coverage depends on the core checkout's runtime TTF -rasterizer. Turkish text requires a core version that includes Latin Extended-A; -the ASCII-only rasterizer does not provide those glyphs. - -The i486 RPM was installed on the Sailfish OS -5.1.0.11 emulator; Tic Tac Toe rendered and mouse clicks changed the board. -Physical device behavior has not been tested. +## Device check + +Manual smoke list for an aarch64 phone (Sailfish OS 5.1.0.11 or newer): + +1. Launch the installed RPM. +2. Tap corner buttons, inputs, and a scrolling view at DPR 1 and DPR 2. +3. Drag and scroll; confirm the control under the finger is the one that moves. +4. Focus a text field. The Sailfish keyboard opens and the field stays visible + after the window shrinks. +5. Type `ç ğ ı İ ö ş ü`, delete each character with Backspace, and press Enter. +6. Repeat in a `type="password"` field (no prediction, hidden text). +7. Dismiss the keyboard and tap the same controls again. Hits stay aligned. +8. Resize or rotate and repeat one tap and one text entry. +9. Before a Harbour upload, run `sfdk check` on the RPM. The keyboard must not + add a `libmaliit` dependency; GIO is the allowed library. + +Recorded on a Redmi Note 8, Sailfish OS 5.1.0.11, aarch64, before this input +and coordinate work landed: an aarch64 RPM installed and the app launched; +touch missed at `devicePixelRatio` 2 until `SDL_WINDOW_ALLOW_HIGHDPI` was +removed, and worked after that; a temporary Maliit connection opened the +keyboard and the window shrank. Character entry, Backspace, password fields, +Enter, hit testing after the keyboard closed, and `sfdk check` were not +verified on device. This tree has not been installed on a phone since those +fixes. + +## Known limits outside this package + +- Runtime TTF coverage, including Turkish letters and symbol fallback, belongs + to `@geastack/engine`. Do not copy a fixed glyph list into this target. +- `declare function` host bindings that throw `throwReferenceError` in + generated C++ belong to `@geastack/compiler`. Declaring `nativeSources` does + not make an undeclared global callable. diff --git a/targets/sailfish-os/build-sailfish-os.ps1 b/targets/sailfish-os/build-sailfish-os.ps1 index a5bc532..b8ebfda 100644 --- a/targets/sailfish-os/build-sailfish-os.ps1 +++ b/targets/sailfish-os/build-sailfish-os.ps1 @@ -3,7 +3,8 @@ param( [ValidateSet('i486','armv7hl','aarch64')][string]$Architecture = 'i486', [string]$CoreRepository = '', [string]$SdkRoot = 'C:\SailfishOS', - [switch]$PrepareOnly + [switch]$PrepareOnly, + [switch]$Clean ) $ErrorActionPreference = 'Stop' $here = Split-Path -Parent $MyInvocation.MyCommand.Path @@ -12,13 +13,34 @@ if ($CoreRepository) { $argsForNode += (Resolve-Path $CoreRepository).Path } & node @argsForNode if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if ($PrepareOnly) { exit 0 } -$project = Join-Path $here "build/$((Get-Content (Join-Path $AppDirectory 'package.json') | ConvertFrom-Json).gea.id)-$Architecture/project" +$appId = (Get-Content (Join-Path $AppDirectory 'package.json') | ConvertFrom-Json).gea.id +$project = Join-Path (Resolve-Path $AppDirectory).Path ".gea-sailfish/build/$appId-$Architecture/project" +$recover = Join-Path $here 'recover-build.mjs' +$recoverArgs = @($recover, $project) +if ($Clean) { $recoverArgs += '--clean' } +& node @recoverArgs +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $sfdk = Join-Path $SdkRoot 'bin/sfdk.exe' if (!(Test-Path $sfdk)) { throw "Sailfish SDK missing at $sfdk" } +$log = Join-Path ([System.IO.Path]::GetTempPath()) ("sailfish-sfdk-" + [guid]::NewGuid().ToString() + ".log") Push-Location $project +$savedErrorAction = $ErrorActionPreference try { - & $sfdk --no-session -c "target=SailfishOS-5.1.0.11-$Architecture" build + # Windows PowerShell 5.1 treats redirected native stderr as terminating when Stop is set. + $ErrorActionPreference = 'Continue' + & $sfdk --no-session -c "target=SailfishOS-5.1.0.11-$Architecture" build 2>&1 | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { $_ } + } | Tee-Object -FilePath $log + $code = $LASTEXITCODE + $ErrorActionPreference = $savedErrorAction + if ($code -ne 0) { + $last = Select-String -Path $log -Pattern 'error:|undefined reference|fatal error:|Error [0-9]' | Select-Object -Last 1 + if ($last) { Write-Host "sfdk build failed (exit $code): $($last.Line)" } + else { Write-Host "sfdk build failed (exit $code)" } + } } finally { + $ErrorActionPreference = $savedErrorAction Pop-Location + Remove-Item -LiteralPath $log -ErrorAction SilentlyContinue } -exit $LASTEXITCODE +exit $code diff --git a/targets/sailfish-os/build-sailfish-os.sh b/targets/sailfish-os/build-sailfish-os.sh index 58969f4..437927f 100755 --- a/targets/sailfish-os/build-sailfish-os.sh +++ b/targets/sailfish-os/build-sailfish-os.sh @@ -8,9 +8,10 @@ ARCH=i486 CORE_REPO="" SFDK_BIN="${SFDK_BIN:-sfdk}" PREPARE_ONLY=0 +CLEAN=0 usage() { - echo "usage: $0 [--arch i486|armv7hl|aarch64] [--core-repo path] [--sfdk path] [--prepare-only]" >&2 + echo "usage: $0 [--arch i486|armv7hl|aarch64] [--core-repo path] [--sfdk path] [--prepare-only] [--clean]" >&2 } while (($#)); do @@ -25,6 +26,7 @@ while (($#)); do shift 2 ;; --prepare-only) PREPARE_ONLY=1; shift ;; + --clean) CLEAN=1; shift ;; -h|--help) usage; exit 0 ;; -*) usage; exit 2 ;; *) @@ -53,5 +55,22 @@ if [[ "$SFDK_BIN" == */* ]]; then fi command -v "$SFDK_BIN" >/dev/null || { echo "sfdk not found: $SFDK_BIN" >&2; exit 1; } APP_ID="$(node -e 'const fs=require("node:fs"); const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); process.stdout.write(p.gea.id)' "$APP_DIR/package.json")" -PROJECT_DIR="$TARGET_DIR/build/$APP_ID-$ARCH/project" -(cd "$PROJECT_DIR" && "$SFDK_BIN" -c "target=SailfishOS-5.1.0.11-$ARCH" build) +PROJECT_DIR="$APP_DIR/.gea-sailfish/build/$APP_ID-$ARCH/project" +RECOVER=(node "$TARGET_DIR/recover-build.mjs" "$PROJECT_DIR") +if ((CLEAN)); then RECOVER+=(--clean); fi +"${RECOVER[@]}" +LOG="$(mktemp)" +set +e +(cd "$PROJECT_DIR" && "$SFDK_BIN" -c "target=SailfishOS-5.1.0.11-$ARCH" build) 2>&1 | tee "$LOG" +SFDK_CODE=${PIPESTATUS[0]} +set -e +if ((SFDK_CODE != 0)); then + LAST_ERROR="$(grep -E 'error:|undefined reference|fatal error:|Error [0-9]' "$LOG" | tail -n 1 || true)" + if [[ -n "$LAST_ERROR" ]]; then + echo "sfdk build failed (exit $SFDK_CODE): $LAST_ERROR" >&2 + else + echo "sfdk build failed (exit $SFDK_CODE)" >&2 + fi +fi +rm -f "$LOG" +exit "$SFDK_CODE" diff --git a/targets/sailfish-os/include/sailfish_keyboard.h b/targets/sailfish-os/include/sailfish_keyboard.h new file mode 100644 index 0000000..a0193b5 --- /dev/null +++ b/targets/sailfish-os/include/sailfish_keyboard.h @@ -0,0 +1,35 @@ +/* targets/sailfish-os/include/sailfish_keyboard.h + * Sailfish system keyboard (Maliit) bridge. + * + * libmaliit-glib is not on Harbour's allowed-library list. This bridge talks + * to the same server through GIO, which is allowed (libgio-2.0.so.0): + * session bus org.maliit.server /org/maliit/server/address + * interface org.maliit.Server.Address, property "address" + * peer bus /com/meego/inputmethod/uiserver1 + * interface com.meego.inputmethod.uiserver1 + * peer bus /com/meego/inputmethod/inputcontext + * interface com.meego.inputmethod.inputcontext1 + * See targets/sailfish-os/README.md. + */ +#pragma once + +typedef bool (*SailfishKeyboardAppendFn)(const char *utf8); +typedef bool (*SailfishKeyboardActionFn)(void); +typedef void (*SailfishKeyboardAreaFn)(int x, int y, int width, int height); + +/* append/backspace/enter are the same helpers the SDL keyboard uses. + * area is called when Maliit reports the keyboard rectangle (including a + * zero height once it is hidden). Failure to reach Maliit is logged and + * ignored so emulator and desktop SDL input still work. */ +void sailfish_keyboard_init(SailfishKeyboardAppendFn append_text, + SailfishKeyboardActionFn backspace, + SailfishKeyboardActionFn enter, + SailfishKeyboardActionFn focus_lost, + SailfishKeyboardAreaFn area); + +void sailfish_keyboard_pump(void); + +/* active_input_id < 0 hides the keyboard. input_type is the focused + * element's type attribute; "password" sets Maliit hiddenText and + * disables prediction. */ +void sailfish_keyboard_update(int active_input_id, const char *input_type); diff --git a/targets/sailfish-os/main/sailfish_audio.cpp b/targets/sailfish-os/main/sailfish_audio.cpp index 6446fba..818a4e5 100644 --- a/targets/sailfish-os/main/sailfish_audio.cpp +++ b/targets/sailfish-os/main/sailfish_audio.cpp @@ -1,56 +1,287 @@ /* targets/sailfish-os/main/sailfish_audio.cpp - * Audio is stubbed on the linux target for now. Apps just want sound to not - * crash the runtime; an SDL_audio/ALSA oscillator backend is a follow-up. - * Every method returns inert values and discards input. - * (Same surface as macos_audio.cpp plus the playFile/playPcm/stopPlayback - * trio geaos_audio.cpp defines — all are required to link host/audio.cpp.) */ + * Oscillator tones go to the Sailfish audio device through SDL2. + * An app schedules a beep by creating an oscillator, + * setting its frequency, connecting it, then start(now)/stop(now + seconds). + * stop() turns that interval into samples. SDL's callback mixes overlapping + * notes. playFile stays unsupported; this target has no WAV file reader. + * The app's Sailjail profile still needs the Audio permission or the device + * open fails and the failure is logged once. */ #include "audio.h" +#include + +#include +#include +#include +#include +#include +#include + namespace gea::platform::audio { namespace { -int g_volume = 80; + +constexpr int kSampleRate = 44100; +constexpr int kMaxOscillators = 8; +constexpr int kMaxTones = 8; +constexpr int kMaxToneMs = 4000; +constexpr int kFadeSamples = kSampleRate / 125; +constexpr double kPi = 3.14159265358979323846; + +struct Oscillator { + bool used = false; + OscillatorType type = OscillatorType::Sine; + double frequencyHz = 440.0; + bool connected = false; + bool started = false; + double startTime = 0.0; +}; + +struct Tone { + bool active = false; + OscillatorType type = OscillatorType::Sine; + double frequencyHz = 440.0; + double phase = 0.0; + int delaySamples = 0; + int remaining = 0; + int total = 0; +}; + +Oscillator g_oscillators[kMaxOscillators]{}; +Tone g_tones[kMaxTones]{}; +int g_nextOscillator = 0; +std::atomic g_volume{80}; +SDL_AudioDeviceID g_device = 0; +bool g_openFailed = false; + +double nowSeconds() +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return static_cast(ts.tv_sec) + static_cast(ts.tv_nsec) / 1000000000.0; +} + +int oscillatorSlot(NativeAudioHandle handle) +{ + if (handle == 0 || handle > static_cast(kMaxOscillators)) return -1; + return static_cast(handle) - 1; +} + +float waveform(OscillatorType type, double phase) +{ + switch (type) { + case OscillatorType::Square: + return phase < 0.5 ? 1.f : -1.f; + case OscillatorType::Sawtooth: + return static_cast(phase * 2.0 - 1.0); + case OscillatorType::Triangle: + return static_cast(phase < 0.5 ? phase * 4.0 - 1.0 : 3.0 - phase * 4.0); + case OscillatorType::Sine: + default: + return static_cast(std::sin(phase * 2.0 * kPi)); + } +} + +void audioCallback(void *, Uint8 *stream, int length) +{ + auto *out = reinterpret_cast(stream); + const int frames = length / static_cast(sizeof(std::int16_t)); + const float gain = (std::clamp(g_volume.load(), 0, 100) / 100.f) * 7000.f; + for (int frame = 0; frame < frames; ++frame) { + float mix = 0.f; + for (Tone &tone : g_tones) { + if (!tone.active) continue; + if (tone.delaySamples > 0) { + --tone.delaySamples; + continue; + } + if (tone.remaining <= 0) { + tone.active = false; + continue; + } + const int played = tone.total - tone.remaining; + float envelope = 1.f; + if (played < kFadeSamples) envelope = played / static_cast(kFadeSamples); + if (tone.remaining < kFadeSamples) { + envelope = std::min(envelope, tone.remaining / static_cast(kFadeSamples)); + } + mix += waveform(tone.type, tone.phase) * envelope; + tone.phase += tone.frequencyHz / kSampleRate; + if (tone.phase >= 1.0) tone.phase -= std::floor(tone.phase); + if (--tone.remaining <= 0) tone.active = false; + } + const float sample = std::clamp(mix * gain, -32768.f, 32767.f); + out[frame] = static_cast(sample); + } +} + +bool ensureDevice() +{ + if (g_device != 0) return true; + if (g_openFailed) return false; + if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) { + std::fprintf(stderr, "[sailfish audio] SDL audio init failed: %s\n", SDL_GetError()); + g_openFailed = true; + return false; + } + SDL_AudioSpec requested{}; + requested.freq = kSampleRate; + requested.format = AUDIO_S16SYS; + requested.channels = 1; + requested.samples = 512; + requested.callback = audioCallback; + SDL_AudioSpec obtained{}; + g_device = SDL_OpenAudioDevice(nullptr, 0, &requested, &obtained, 0); + if (g_device == 0) { + std::fprintf(stderr, "[sailfish audio] device open failed: %s\n", SDL_GetError()); + g_openFailed = true; + return false; + } + SDL_PauseAudioDevice(g_device, 0); + std::fprintf(stderr, "[sailfish audio] open %d Hz\n", obtained.freq); + return true; +} + +void submitTone(OscillatorType type, double frequencyHz, int durationMs, int delayMs) +{ + if (frequencyHz <= 0.0 || durationMs <= 0) return; + if (durationMs > kMaxToneMs) durationMs = kMaxToneMs; + if (delayMs < 0) delayMs = 0; + if (!ensureDevice()) return; + + SDL_LockAudioDevice(g_device); + Tone *slot = nullptr; + for (Tone &tone : g_tones) { + if (!tone.active) { + slot = &tone; + break; + } + } + if (!slot) slot = &g_tones[0]; + slot->active = true; + slot->type = type; + slot->frequencyHz = frequencyHz; + slot->phase = 0.0; + slot->delaySamples = (delayMs * kSampleRate) / 1000; + slot->total = (durationMs * kSampleRate) / 1000; + slot->remaining = slot->total; + SDL_UnlockAudioDevice(g_device); } -// AudioParam +} // namespace + AudioParam::AudioParam(NativeAudioHandle oscillator) : oscillator_(oscillator) {} -double AudioParam::value() const { return 0.0; } -void AudioParam::setValue(double) {} -void AudioParam::setValueAtTime(double, double) {} -// AudioNode +double AudioParam::value() const +{ + const int slot = oscillatorSlot(oscillator_); + if (slot < 0) return 0.0; + return g_oscillators[slot].frequencyHz; +} + +void AudioParam::setValue(double value) +{ + const int slot = oscillatorSlot(oscillator_); + if (slot < 0) return; + g_oscillators[slot].frequencyHz = value; +} + +void AudioParam::setValueAtTime(double value, double) +{ + setValue(value); +} + AudioNode::AudioNode(NativeAudioHandle native) : native_(native) {} NativeAudioHandle AudioNode::nativeId() const { return native_; } -// AudioDestinationNode AudioDestinationNode::AudioDestinationNode(NativeAudioHandle native) : AudioNode(native) {} -// OscillatorNode OscillatorNode::OscillatorNode(NativeAudioHandle native) : AudioNode(native), frequency(native) {} -OscillatorType OscillatorNode::type() const { return OscillatorType::Sine; } -void OscillatorNode::setType(OscillatorType) {} -void OscillatorNode::connect(const AudioDestinationNode &) {} -void OscillatorNode::start(double) {} -void OscillatorNode::stop(double) {} - -// AudioContext -double AudioContext::currentTime() const { return 0.0; } -AudioDestinationNode AudioContext::destination() const { return AudioDestinationNode(0); } -OscillatorNode AudioContext::createOscillator() const { return OscillatorNode(0); } - -// AudioSystem + +OscillatorType OscillatorNode::type() const +{ + const int slot = oscillatorSlot(nativeId()); + if (slot < 0) return OscillatorType::Sine; + return g_oscillators[slot].type; +} + +void OscillatorNode::setType(OscillatorType type) +{ + const int slot = oscillatorSlot(nativeId()); + if (slot < 0) return; + g_oscillators[slot].type = type; +} + +void OscillatorNode::connect(const AudioDestinationNode &) +{ + const int slot = oscillatorSlot(nativeId()); + if (slot < 0) return; + g_oscillators[slot].connected = true; +} + +void OscillatorNode::start(double when) +{ + const int slot = oscillatorSlot(nativeId()); + if (slot < 0) return; + if (when <= 0.0) when = nowSeconds(); + g_oscillators[slot].startTime = when; + g_oscillators[slot].started = true; +} + +void OscillatorNode::stop(double when) +{ + const int slot = oscillatorSlot(nativeId()); + if (slot < 0) return; + Oscillator &oscillator = g_oscillators[slot]; + if (!oscillator.connected) return; + + const double now = nowSeconds(); + const double startTime = oscillator.started ? oscillator.startTime : now; + if (when <= 0.0) when = now; + if (when < startTime) when = startTime; + const int durationMs = static_cast((when - startTime) * 1000.0 + 0.5); + const int delayMs = startTime > now ? static_cast((startTime - now) * 1000.0 + 0.5) : 0; + submitTone(oscillator.type, oscillator.frequencyHz, durationMs, delayMs); + oscillator.started = false; +} + +double AudioContext::currentTime() const { return nowSeconds(); } + +AudioDestinationNode AudioContext::destination() const { return AudioDestinationNode(1); } + +OscillatorNode AudioContext::createOscillator() const +{ + const int slot = g_nextOscillator; + g_nextOscillator = (g_nextOscillator + 1) % kMaxOscillators; + g_oscillators[slot] = Oscillator{}; + g_oscillators[slot].used = true; + return OscillatorNode(static_cast(slot + 1)); +} + AudioContext AudioSystem::sharedContext() { return AudioContext{}; } -int AudioSystem::volume() { return g_volume; } -void AudioSystem::setVolume(int v) + +int AudioSystem::volume() { return g_volume.load(); } + +void AudioSystem::setVolume(int volume) { - if (v < 0) v = 0; - if (v > 100) v = 100; - g_volume = v; + if (volume < 0) volume = 0; + if (volume > 100) volume = 100; + g_volume.store(volume); } + bool AudioSystem::playFile(const std::string &) { return false; } + bool AudioSystem::playPcm(const std::int16_t *, std::size_t, int, int) { return false; } -void AudioSystem::stopPlayback() {} + +void AudioSystem::stopPlayback() +{ + if (g_device == 0) return; + SDL_LockAudioDevice(g_device); + for (Tone &tone : g_tones) tone = Tone{}; + SDL_UnlockAudioDevice(g_device); +} + // No PCM stream output is queued by this backend (only the ESP32 runtime // streams PCM), so a flush has nothing to discard. void AudioSystem::flushPlayback() {} diff --git a/targets/sailfish-os/main/sailfish_display.cpp b/targets/sailfish-os/main/sailfish_display.cpp index 791519e..2271a68 100644 --- a/targets/sailfish-os/main/sailfish_display.cpp +++ b/targets/sailfish-os/main/sailfish_display.cpp @@ -13,10 +13,11 @@ * - Display::pushClip / popClip forward to the Canvas clip stack — the * framework relies on a working clip to constrain each dirty-region * replay; stubbing them tanks fps (see the geaos header comment). - * - The window is an integer-scaled view of the logical canvas + * - The window is an integer-scaled view of the canvas * (GEA_SAILFISH_SCALE, default 1). After a window resize, the canvas is - * resized to window size / scale. At the default scale and DPR of 1, - * one CSS pixel is one canvas pixel and one SDL window coordinate unit. + * resized to window size / scale. Pointer events are window points and + * are converted to canvas pixels by sailfish_window_to_canvas(). CSS + * pixels are canvas pixels / DPR and are not a second pointer scale. * * Window size / scale / DPR come from the environment so apps tuned for a * particular panel can be reproduced exactly: @@ -115,7 +116,12 @@ void ensure_window() #ifndef GEA_SAILFISH_APP_TITLE #define GEA_SAILFISH_APP_TITLE "gea" #endif - int window_flags = SDL_WINDOW_SHOWN | SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_RESIZABLE; + // No SDL_WINDOW_ALLOW_HIGHDPI. Gea devicePixelRatio is already canvas pixels + // per CSS pixel. The high-DPI flag makes the drawable larger than the window + // and, with a logical renderer size, scales normalized touches again. On + // device that sent DPR 2 taps to the wrong control. The texture is stretched + // to the window instead; sailfish_window_to_canvas() maps pointers. + int window_flags = SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE; window_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP; g_window = SDL_CreateWindow(GEA_SAILFISH_APP_TITLE, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, @@ -137,9 +143,9 @@ void ensure_window() std::fprintf(stderr, "[sailfish display] SDL_CreateRenderer failed: %s\n", SDL_GetError()); return; } - // Integer-scale the logical canvas to the window; nearest keeps pixels crisp. + // Nearest keeps integer window scaling crisp. The texture is the canvas; + // RenderCopy stretches it to the window. Pointers are not scaled here. SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "nearest"); - SDL_RenderSetLogicalSize(g_renderer, g_canvas_width, g_canvas_height); g_texture = SDL_CreateTexture(g_renderer, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STREAMING, g_canvas_width, g_canvas_height); @@ -149,9 +155,12 @@ void ensure_window() } SDL_RendererInfo info{}; SDL_GetRendererInfo(g_renderer, &info); + int window_w = 0, window_h = 0, drawable_w = 0, drawable_h = 0; + SDL_GetWindowSize(g_window, &window_w, &window_h); + SDL_GetRendererOutputSize(g_renderer, &drawable_w, &drawable_h); g_sdl_ok = true; - std::fprintf(stderr, "[sailfish display] SDL window %dx%d (scale %d, renderer %s%s)\n", - g_canvas_width, g_canvas_height, g_window_scale, info.name, + std::fprintf(stderr, "[sailfish display] SDL window %dx%d drawable %dx%d canvas %dx%d (scale %d, renderer %s%s)\n", + window_w, window_h, drawable_w, drawable_h, g_canvas_width, g_canvas_height, g_window_scale, info.name, (info.flags & SDL_RENDERER_PRESENTVSYNC) ? ", vsync" : ""); } @@ -246,6 +255,44 @@ extern "C" void sailfish_display_toggle_fullscreen() extern "C" int sailfish_display_window_scale() { return g_window_scale; } +extern "C" void sailfish_window_size(int *width, int *height) +{ + int w = g_canvas_width * g_window_scale; + int h = g_canvas_height * g_window_scale; + if (g_window) SDL_GetWindowSize(g_window, &w, &h); + if (w < 1) w = 1; + if (h < 1) h = 1; + if (width) *width = w; + if (height) *height = h; +} + +// Three spaces: +// window points — SDL_GetWindowSize and SDL mouse/wheel coordinates; +// finger events are 0..1 of this window +// drawable pixels — SDL_GetRendererOutputSize. This matches window points +// because the window is created without ALLOW_HIGHDPI. +// Do not multiply a pointer by drawable/window: that is the +// second scale that missed controls at devicePixelRatio 2. +// canvas pixels — the framebuffer and the hit-test space. CSS pixels are +// canvas / DPR inside the framework and are not applied here. +// canvas = window_point * canvas_size / window_size, then clamped. +extern "C" void sailfish_window_to_canvas(int window_x, int window_y, int *canvas_x, int *canvas_y) +{ + int window_w = 1; + int window_h = 1; + sailfish_window_size(&window_w, &window_h); + long long x = (long long)window_x * g_canvas_width / window_w; + long long y = (long long)window_y * g_canvas_height / window_h; + if (x < 0) x = 0; + if (y < 0) y = 0; + const int maxX = g_canvas_width > 0 ? g_canvas_width - 1 : 0; + const int maxY = g_canvas_height > 0 ? g_canvas_height - 1 : 0; + if (x > maxX) x = maxX; + if (y > maxY) y = maxY; + if (canvas_x) *canvas_x = (int)x; + if (canvas_y) *canvas_y = (int)y; +} + // Rebuild the framebuffer + streaming texture at a new logical size (window // resized or fullscreen toggled). The canvas rebinds to the new buffer; the // caller (sailfish_main) then updates the framework viewport metrics and marks @@ -281,7 +328,6 @@ extern "C" int sailfish_display_resize(int new_width, int new_height) g_canvas_width = new_width; g_canvas_height = new_height; g_canvas.bindPixels(g_framebuffer, g_canvas_width, g_canvas_height); - if (g_sdl_ok) SDL_RenderSetLogicalSize(g_renderer, g_canvas_width, g_canvas_height); const int cap = new_width * new_height; if (g_backdrop_buffer && cap > g_backdrop_cap) { diff --git a/targets/sailfish-os/main/sailfish_keyboard.cpp b/targets/sailfish-os/main/sailfish_keyboard.cpp new file mode 100644 index 0000000..1700897 --- /dev/null +++ b/targets/sailfish-os/main/sailfish_keyboard.cpp @@ -0,0 +1,315 @@ +/* targets/sailfish-os/main/sailfish_keyboard.cpp + * Shows and hides the Sailfish system keyboard and forwards its commit + * string, Backspace, and Enter into the shared input helpers. + * + * Harbour does not allow linking libmaliit-glib (it is absent from + * sdk-harbour-rpmvalidator allowed_libraries.conf and from + * https://docs.sailfishos.org/Develop/Apps/Harbour/Allowed_APIs/). GIO is + * allowed, so the bridge uses the Maliit D-Bus API directly. The Gea + * on-screen keyboard stays compiled out (GEA_EMBEDDED_ENABLE_VIRTUAL_KEYBOARD=0); + * SDL text input remains for the emulator and a hardware keyboard. + */ + +#include "sailfish_keyboard.h" + +#include + +#include +#include + +namespace { + +constexpr int kQtKeyRelease = 7; +constexpr int kQtKeyBackspace = 0x01000003; +constexpr int kQtKeyReturn = 0x01000004; +constexpr int kQtKeyEnter = 0x01000005; + +const char *kContextXml = + "" + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + ""; + +SailfishKeyboardAppendFn g_append = nullptr; +SailfishKeyboardActionFn g_backspace = nullptr; +SailfishKeyboardActionFn g_enter = nullptr; +SailfishKeyboardActionFn g_focus_lost = nullptr; +SailfishKeyboardAreaFn g_area = nullptr; + +GDBusConnection *g_peer = nullptr; +GDBusNodeInfo *g_node_info = nullptr; +bool g_visible = false; +int g_focused_id = -2; +bool g_hidden_text = false; +int g_area_x = 0; +int g_area_y = 0; +int g_area_w = 0; +int g_area_h = -1; + +void complete_empty(GDBusMethodInvocation *invocation) +{ + g_dbus_method_invocation_return_value(invocation, nullptr); +} + +void on_method_call(GDBusConnection *, const gchar *, const gchar *, const gchar *, + const gchar *method, GVariant *params, GDBusMethodInvocation *invocation, gpointer) +{ + if (std::strcmp(method, "commitString") == 0) { + const char *text = nullptr; + gint replacement_start = 0; + gint replacement_length = 0; + gint cursor = 0; + g_variant_get(params, "(siii)", &text, &replacement_start, &replacement_length, &cursor); + if (text && g_append) g_append(text); + complete_empty(invocation); + return; + } + if (std::strcmp(method, "keyEvent") == 0) { + gint type = 0; + gint key = 0; + gint modifiers = 0; + const char *text = nullptr; + gboolean auto_repeat = FALSE; + gint count = 0; + guchar scan = 0; + g_variant_get(params, "(iiisbiy)", &type, &key, &modifiers, &text, &auto_repeat, &count, &scan); + (void)modifiers; + (void)text; + (void)auto_repeat; + (void)count; + (void)scan; + // KeyRelease would delete or submit twice. Any other type is a press. + // QEvent::KeyRelease is 7. Acting on it as well would delete or submit twice. + // KeyPress is 6; any other non-release type is treated as a press. + if (type != kQtKeyRelease) { + if (key == kQtKeyBackspace && g_backspace) g_backspace(); + else if ((key == kQtKeyReturn || key == kQtKeyEnter) && g_enter) g_enter(); + } + complete_empty(invocation); + return; + } + if (std::strcmp(method, "updateInputMethodArea") == 0) { + gint x = 0, y = 0, width = 0, height = 0; + g_variant_get(params, "(iiii)", &x, &y, &width, &height); + if (x != g_area_x || y != g_area_y || width != g_area_w || height != g_area_h) { + g_area_x = x; + g_area_y = y; + g_area_w = width; + g_area_h = height; + if (g_area) g_area(x, y, width, height); + } + complete_empty(invocation); + return; + } + if (std::strcmp(method, "imInitiatedHide") == 0) { + g_visible = false; + g_focused_id = -1; + if (g_focus_lost) g_focus_lost(); + complete_empty(invocation); + return; + } + if (std::strcmp(method, "preeditRectangle") == 0) { + g_dbus_method_invocation_return_value(invocation, g_variant_new("(biiii)", FALSE, 0, 0, 0, 0)); + return; + } + if (std::strcmp(method, "selection") == 0) { + g_dbus_method_invocation_return_value(invocation, g_variant_new("(bs)", FALSE, "")); + return; + } + complete_empty(invocation); +} + +const GDBusInterfaceVTable kVTable = { on_method_call, nullptr, nullptr }; + +bool call_server(const char *method, GVariant *args) +{ + if (!g_peer) { + if (args) { + if (g_variant_is_floating(args)) g_variant_ref_sink(args); + g_variant_unref(args); + } + return false; + } + GError *error = nullptr; + GVariant *reply = g_dbus_connection_call_sync( + g_peer, nullptr, "/com/meego/inputmethod/uiserver1", + "com.meego.inputmethod.uiserver1", method, args, nullptr, + G_DBUS_CALL_FLAGS_NONE, 500, nullptr, &error); + if (!reply) { + std::fprintf(stderr, "[sailfish keyboard] %s failed: %s\n", method, error ? error->message : "unknown"); + if (error) g_error_free(error); + return false; + } + g_variant_unref(reply); + return true; +} + +void update_widget(bool focused, bool hidden, bool focus_changed) +{ + GVariantBuilder builder; + g_variant_builder_init(&builder, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&builder, "{sv}", "focusState", g_variant_new_boolean(focused)); + g_variant_builder_add(&builder, "{sv}", "hiddenText", g_variant_new_boolean(hidden)); + g_variant_builder_add(&builder, "{sv}", "predictionEnabled", g_variant_new_boolean(!hidden)); + GVariant *state = g_variant_builder_end(&builder); + call_server("updateWidgetInformation", g_variant_new("(@a{sv}b)", state, focus_changed ? TRUE : FALSE)); +} + +} // namespace + +void sailfish_keyboard_init(SailfishKeyboardAppendFn append_text, + SailfishKeyboardActionFn backspace, + SailfishKeyboardActionFn enter, + SailfishKeyboardActionFn focus_lost, + SailfishKeyboardAreaFn area) +{ + g_append = append_text; + g_backspace = backspace; + g_enter = enter; + g_focus_lost = focus_lost; + g_area = area; + + const char *address = g_getenv("MALIIT_SERVER_ADDRESS"); + gchar *owned_address = nullptr; + GError *error = nullptr; + if (!address || !*address) { + GDBusConnection *session = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error); + if (!session) { + std::fprintf(stderr, "[sailfish keyboard] session bus unavailable: %s\n", + error ? error->message : "unknown"); + if (error) g_error_free(error); + return; + } + GVariant *reply = g_dbus_connection_call_sync( + session, "org.maliit.server", "/org/maliit/server/address", + "org.freedesktop.DBus.Properties", "Get", + g_variant_new("(ss)", "org.maliit.Server.Address", "address"), + G_VARIANT_TYPE("(v)"), G_DBUS_CALL_FLAGS_NONE, 1000, nullptr, &error); + g_object_unref(session); + if (!reply) { + std::fprintf(stderr, "[sailfish keyboard] Maliit address unavailable: %s\n", + error ? error->message : "unknown"); + if (error) g_error_free(error); + return; + } + GVariant *inner = nullptr; + g_variant_get(reply, "(v)", &inner); + g_variant_unref(reply); + if (!inner || !g_variant_is_of_type(inner, G_VARIANT_TYPE_STRING)) { + if (inner) g_variant_unref(inner); + std::fprintf(stderr, "[sailfish keyboard] Maliit address property is not a string\n"); + return; + } + owned_address = g_variant_dup_string(inner, nullptr); + g_variant_unref(inner); + address = owned_address; + } + + g_peer = g_dbus_connection_new_for_address_sync( + address, G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT, nullptr, nullptr, &error); + g_free(owned_address); + if (!g_peer) { + std::fprintf(stderr, "[sailfish keyboard] Maliit peer bus failed: %s\n", + error ? error->message : "unknown"); + if (error) g_error_free(error); + return; + } + + g_node_info = g_dbus_node_info_new_for_xml(kContextXml, &error); + if (!g_node_info) { + std::fprintf(stderr, "[sailfish keyboard] context interface failed: %s\n", + error ? error->message : "unknown"); + if (error) g_error_free(error); + g_clear_object(&g_peer); + return; + } + if (g_dbus_connection_register_object(g_peer, "/com/meego/inputmethod/inputcontext", + g_node_info->interfaces[0], &kVTable, nullptr, nullptr, + &error) == 0) { + std::fprintf(stderr, "[sailfish keyboard] context export failed: %s\n", + error ? error->message : "unknown"); + if (error) g_error_free(error); + g_clear_object(&g_peer); + return; + } + std::fprintf(stderr, "[sailfish keyboard] Maliit ready\n"); +} + +void sailfish_keyboard_pump(void) +{ + if (!g_peer) return; + while (g_main_context_iteration(nullptr, FALSE)) {} +} + +void sailfish_keyboard_update(int active_input_id, const char *input_type) +{ + if (!g_peer) return; + const bool show = active_input_id >= 0; + const bool hidden = input_type && std::strcmp(input_type, "password") == 0; + if (show == g_visible && active_input_id == g_focused_id && hidden == g_hidden_text) return; + + const bool focus_changed = active_input_id != g_focused_id; + g_focused_id = active_input_id; + g_hidden_text = hidden; + update_widget(show, hidden, focus_changed); + if (show == g_visible) return; + + call_server("activateContext", nullptr); + if (show) call_server("showInputMethod", nullptr); + else call_server("hideInputMethod", nullptr); + g_visible = show; + std::fprintf(stderr, "[sailfish keyboard] %s input=%d password=%d\n", + show ? "show" : "hide", active_input_id, hidden ? 1 : 0); +} diff --git a/targets/sailfish-os/main/sailfish_main.cpp b/targets/sailfish-os/main/sailfish_main.cpp index f4dbf49..4098fcb 100644 --- a/targets/sailfish-os/main/sailfish_main.cpp +++ b/targets/sailfish-os/main/sailfish_main.cpp @@ -43,6 +43,7 @@ #include "ui/style.h" #include "ui/tree_internal.h" #include "touch.h" +#include "sailfish_keyboard.h" #include @@ -64,6 +65,8 @@ extern "C" void sailfish_display_present(); extern "C" void sailfish_display_toggle_fullscreen(); extern "C" int sailfish_display_window_scale(); extern "C" int sailfish_display_resize(int new_width, int new_height); +extern "C" void sailfish_window_size(int *width, int *height); +extern "C" void sailfish_window_to_canvas(int window_x, int window_y, int *canvas_x, int *canvas_y); extern "C" int gea_embedded_apps_launch(const char *app_id); extern "C" void sailfish_runtime_storage_load(); extern "C" void sailfish_runtime_storage_flush(); @@ -111,21 +114,13 @@ bool g_resize_pending = false; // window size changed — apply before next fra int g_resize_window_w = 0; int g_resize_window_h = 0; -// Inject an SDL mouse position as a touch. SDL mouse-event coordinates are -// ALREADY in logical/canvas space: SDL_RenderSetLogicalSize installs a -// renderer event watch that rewrites SDL_MOUSEMOTION/BUTTON coordinates into -// the logical coordinate system. Do NOT call SDL_RenderWindowToLogical here — -// that scales a second time and lands every touch at half the true position -// (found via a uinput-verified drag that hit-tested at ~(126,185) when the -// cursor sat at logical (254,387)). -void injectMouse(gea::platform::touch::Phase phase, bool touching, int x, int y) +// Mouse, finger, and wheel positions are window points. sailfish_window_to_canvas +// is the only conversion into canvas pixels (the hit-test space). +void injectMouse(gea::platform::touch::Phase phase, bool touching, int windowX, int windowY) { - const int maxX = sailfish_canvas_width() - 1; - const int maxY = sailfish_canvas_height() - 1; - if (x < 0) x = 0; - if (y < 0) y = 0; - if (x > maxX) x = maxX; - if (y > maxY) y = maxY; + int x = 0; + int y = 0; + sailfish_window_to_canvas(windowX, windowY, &x, &y); gea::platform::touch::Touchscreen::injectEvent(phase, touching, x, y); } @@ -157,16 +152,14 @@ int fingerSlot(SDL_FingerID id, bool allocate) void injectFinger(gea::platform::touch::Phase phase, bool touching, float nx, float ny, int pointerId) { - // SDL_RenderSetLogicalSize's event watch already normalizes finger events - // to the logical viewport, including letterbox offsets. Scale only once. - int x = (int)(nx * (float)sailfish_canvas_width()); - int y = (int)(ny * (float)sailfish_canvas_height()); - const int maxX = sailfish_canvas_width() - 1; - const int maxY = sailfish_canvas_height() - 1; - if (x < 0) x = 0; - if (y < 0) y = 0; - if (x > maxX) x = maxX; - if (y > maxY) y = maxY; + // SDL finger coordinates are normalized across the window. Turn them into + // window points, then use the same window-to-canvas mapping as the mouse. + int windowW = 1; + int windowH = 1; + sailfish_window_size(&windowW, &windowH); + int x = 0; + int y = 0; + sailfish_window_to_canvas((int)(nx * (float)windowW), (int)(ny * (float)windowH), &x, &y); gea::framework::events::TouchRuntime::queueTouchEvent( static_cast(phase), touching, x, y, pointerId); } @@ -213,9 +206,68 @@ void markNodeFullyDirty(gea::embedded::ui::Tree &tree, int nodeId) node.render.non_scroll_dirty = 1; } -// Physical-keyboard text entry: mutate the focused 's value attribute -// and fire the synthetic input/keydown events, mirroring what the on-screen -// virtual keyboard does for taps. Returns false when no input is focused. +// Well-formed UTF-8 sequence length at p, or 0 when the bytes are not a single +// Unicode scalar value. Overlong encodings and surrogates are rejected. +int utf8SequenceLength(const unsigned char *p, const unsigned char *end) +{ + if (p >= end) return 0; + const unsigned char lead = *p; + int need = 0; + std::uint32_t codePoint = 0; + if (lead < 0x80) return 1; + if ((lead & 0xE0) == 0xC0) { need = 2; codePoint = lead & 0x1F; } + else if ((lead & 0xF0) == 0xE0) { need = 3; codePoint = lead & 0x0F; } + else if ((lead & 0xF8) == 0xF0) { need = 4; codePoint = lead & 0x07; } + else return 0; + if (p + need > end) return 0; + for (int i = 1; i < need; i++) { + if ((p[i] & 0xC0) != 0x80) return 0; + codePoint = (codePoint << 6) | (p[i] & 0x3F); + } + if (need == 2 && codePoint < 0x80) return 0; + if (need == 3 && codePoint < 0x800) return 0; + if (need == 4 && codePoint < 0x10000) return 0; + if (codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) return 0; + return need; +} + +void appendUtf8(std::string &dest, const char *utf8) +{ + if (!utf8 || !*utf8) return; + const auto *p = reinterpret_cast(utf8); + const auto *end = p + std::strlen(utf8); + while (p < end) { + const int length = utf8SequenceLength(p, end); + if (length <= 0) { + p++; + continue; + } + dest.append(reinterpret_cast(p), static_cast(length)); + p += length; + } +} + +// Deletes the last Unicode code point. Trailing UTF-8 continuation bytes +// (10xxxxxx) are dropped first, then the lead byte. A grapheme cluster made of +// several code points is not collapsed; one code point meets the Sailfish +// text-input requirement, including ç ğ ı İ ö ş ü. +void popUtf8CodePoint(std::string &text) +{ + while (!text.empty() && (static_cast(text.back()) & 0xC0) == 0x80) + text.pop_back(); + if (!text.empty()) text.pop_back(); +} + +void dispatchInputEvent(gea::embedded::ui::Tree &tree, int activeId) +{ + gea::framework::events::PointerEvent ev{}; + ev.type = gea::framework::events::PointerEventType::Input; + ev.targetId = activeId; + tree.dispatchEvent(ev); +} + +// Shared by SDL_TEXTINPUT and the Maliit commit-string path. Returns false +// when no input is focused. Valid UTF-8 is inserted whole; ASCII is unchanged. bool appendTextToActiveInput(const char *utf8) { auto &tree = gea::embedded::ui::Tree::instance(); @@ -224,22 +276,12 @@ bool appendTextToActiveInput(const char *utf8) const char *currentValue = tree.getAttribute(activeId, "value"); std::string next = currentValue ? std::string(currentValue) : std::string(); - bool changed = false; - for (const char *p = utf8; *p; p++) { - // ASCII printable only — the raster pipeline's fonts cover ASCII. - if (*p >= 32 && *p < 127) { - next.push_back(*p); - changed = true; - } - } - if (!changed) return true; + const size_t before = next.size(); + appendUtf8(next, utf8); + if (next.size() == before) return true; tree.setAttribute(activeId, "value", next.c_str()); markNodeFullyDirty(tree, activeId); - - gea::framework::events::PointerEvent ev{}; - ev.type = gea::framework::events::PointerEventType::Input; - ev.targetId = activeId; - tree.dispatchEvent(ev); + dispatchInputEvent(tree, activeId); return true; } @@ -252,13 +294,10 @@ bool applyBackspaceToActiveInput() const char *currentValue = tree.getAttribute(activeId, "value"); std::string next = currentValue ? std::string(currentValue) : std::string(); if (!next.empty()) { - next.pop_back(); + popUtf8CodePoint(next); tree.setAttribute(activeId, "value", next.c_str()); markNodeFullyDirty(tree, activeId); - gea::framework::events::PointerEvent ev{}; - ev.type = gea::framework::events::PointerEventType::Input; - ev.targetId = activeId; - tree.dispatchEvent(ev); + dispatchInputEvent(tree, activeId); } gea::framework::events::PointerEvent kd{}; kd.type = gea::framework::events::PointerEventType::KeyDown; @@ -268,6 +307,29 @@ bool applyBackspaceToActiveInput() return true; } +// Enter from a hardware key and from Maliit (Qt::Key_Return / Qt::Key_Enter) +// both queue web keyCode 13, which dispatchKeyInput delivers to the focused input. +bool applyEnterToActiveInput() +{ + auto &tree = gea::embedded::ui::Tree::instance(); + if (tree.activeInputId() < 0) return false; + gea::framework::input::queueKeyDown(13); + return true; +} + +bool clearActiveInputFocus() +{ + gea::embedded::ui::Tree::instance().setActiveInput(-1); + return true; +} + +void keepFocusedInputVisible(int, int, int, int) +{ + auto &tree = gea::embedded::ui::Tree::instance(); + const int activeId = tree.activeInputId(); + if (activeId >= 0) tree.scrollIntoView(activeId); +} + // ---- per-frame input drains (mirrors core/runtime.cpp, which this target // excludes because it runs its own loop) ------------------------------------ @@ -323,10 +385,11 @@ void pumpSdlEvents() sailfish_display_toggle_fullscreen(); break; } - // Backspace/Enter on a focused take the virtual-keyboard - // path (value mutation + synthetic events); everything else is - // queued as a web keyCode and drained by dispatchKeyInput. + // Backspace and Enter on a focused use the same helpers as + // the Sailfish system keyboard. Everything else is queued as a + // web keyCode and drained by dispatchKeyInput. if (sym == SDLK_BACKSPACE && applyBackspaceToActiveInput()) break; + if ((sym == SDLK_RETURN || sym == SDLK_KP_ENTER) && applyEnterToActiveInput()) break; const int keyCode = webKeyCodeForSdl(sym); if (keyCode != 0) gea::framework::input::queueKeyDown(keyCode); break; @@ -342,13 +405,21 @@ void pumpSdlEvents() int notches = event.wheel.y; if (event.wheel.direction == SDL_MOUSEWHEEL_FLIPPED) notches = -notches; if (notches != 0) { + int canvasX = 0; + int canvasY = 0; +#if SDL_VERSION_ATLEAST(2, 0, 18) + sailfish_window_to_canvas(event.wheel.mouseX, event.wheel.mouseY, &canvasX, &canvasY); +#else + sailfish_window_to_canvas(0, 0, &canvasX, &canvasY); +#endif // Wheel-up (notches > 0) scrolls toward the top: negative dy. const bool scrolled = gea::embedded::ui::Tree::instance().scrollByKeyStep(-notches * 48); // Also surface it as rotary detents for apps that listen // (knob-first apps from the elecrow rotary board). gea::framework::input::queueRotaryDelta(-notches); if (inputTrace) - std::fprintf(stderr, "[sailfish.input] wheel notches=%d scrolled=%d\n", notches, scrolled ? 1 : 0); + std::fprintf(stderr, "[sailfish.input] wheel notches=%d scrolled=%d canvas=%d,%d\n", + notches, scrolled ? 1 : 0, canvasX, canvasY); } break; } @@ -525,8 +596,12 @@ int main(int argc, char **argv) std::fprintf(stderr, "[sailfish] touch input unavailable\n"); } - // Physical-keyboard text entry for focused nodes (SDL_TEXTINPUT). + // Hardware and emulator keyboards still arrive as SDL_TEXTINPUT. The Gea + // on-screen keyboard is compiled out; a Sailfish device uses Maliit. SDL_StartTextInput(); + sailfish_keyboard_init(appendTextToActiveInput, applyBackspaceToActiveInput, + applyEnterToActiveInput, clearActiveInputFocus, + keepFocusedInputVisible); auto &tree = gea::embedded::ui::Tree::instance(); @@ -546,6 +621,7 @@ int main(int argc, char **argv) clock_gettime(CLOCK_MONOTONIC, &t0); pumpSdlEvents(); + sailfish_keyboard_pump(); // Minimized/hidden: keep pumping events (so quit works) but skip // frames entirely; input dispatch is gated off in setPaused. @@ -571,12 +647,25 @@ int main(int argc, char **argv) gea::embedded::ui::dispatchWindowLifecycleEvent(gea::framework::events::PointerEventType::WindowResize); const int count = tree.nodeCount(); for (int id = 0; id < count; ++id) markNodeFullyDirty(tree, id); + // The Sailfish keyboard shrinks the window. Scroll the focused + // field back into the new viewport once layout runs. + if (tree.activeInputId() >= 0) tree.scrollIntoView(tree.activeInputId()); std::fprintf(stderr, "[sailfish] resized to %dx%d (window %dx%d)\n", w, h, g_resize_window_w, g_resize_window_h); } } dispatchPendingEvents(); + { + static int lastFocusedInput = -1; + const int focusedInput = tree.activeInputId(); + const char *inputType = focusedInput >= 0 ? tree.getAttribute(focusedInput, "type") : nullptr; + sailfish_keyboard_update(focusedInput, inputType); + if (focusedInput != lastFocusedInput) { + lastFocusedInput = focusedInput; + if (focusedInput >= 0) tree.scrollIntoView(focusedInput); + } + } struct timespec t_af0; clock_gettime(CLOCK_MONOTONIC, &t_af0); diff --git a/targets/sailfish-os/prepare-lib.mjs b/targets/sailfish-os/prepare-lib.mjs new file mode 100644 index 0000000..13a1164 --- /dev/null +++ b/targets/sailfish-os/prepare-lib.mjs @@ -0,0 +1,67 @@ +import fs from 'node:fs' +import path from 'node:path' + +const frameworkPackages = ['core', 'host', 'engine', 'elements', 'geaos'] +const stagedCopyExcluded = new Set(['.git', 'node_modules', 'dist', 'build', 'test', 'tests', '.build-test']) + +export function shouldCopyStagedPath(sourcePath, copiedRoot = '') { + if (copiedRoot && path.resolve(sourcePath) === path.resolve(copiedRoot)) return true + return !stagedCopyExcluded.has(path.basename(sourcePath)) +} + +export function frameworkPackageNames() { + return frameworkPackages +} + +export function appRelative(appDir, rel) { + if (typeof rel !== 'string' || rel.length === 0 || path.isAbsolute(rel)) { + throw new Error(`Sailfish native path must be relative to the app: ${rel}`) + } + if (rel.split(/[\\/]/).includes('..')) { + throw new Error(`Sailfish native path must stay inside the app: ${rel}`) + } + const root = path.resolve(appDir) + const abs = path.resolve(root, rel) + if (abs !== root && !abs.startsWith(root + path.sep)) { + throw new Error(`Sailfish native path must stay inside the app: ${rel}`) + } + return abs +} + +export function resolveGeastackPackage(appDir, linuxRoot, name) { + const candidates = [ + path.join(appDir, 'node_modules', '@geastack', name), + path.join(linuxRoot, 'node_modules', '@geastack', name), + ] + for (const dir of candidates) { + if (fs.existsSync(path.join(dir, 'package.json'))) return dir + } + throw new Error(`missing @geastack/${name}. Run npm ci in the app so the locked package is installed.`) +} + +export function readPackageVersion(packageJsonPath) { + if (!fs.existsSync(packageJsonPath)) return '' + const version = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version + return typeof version === 'string' ? version : '' +} + +export function assertPackageVersion(name, installedVersion, checkoutVersion) { + if (!installedVersion || !checkoutVersion || installedVersion !== checkoutVersion) { + throw new Error(`@geastack/${name} version mismatch: app package ${installedVersion || 'missing'}, checkout ${checkoutVersion || 'missing'}. Use a checkout matching the app's installed package, or omit the core checkout to build from the app's packages.`) + } +} + +export function validateLibraryName(name) { + if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.+-]*$/.test(name)) { + throw new Error(`gea.sailfish.libraries entries must be pkg-config module names: ${name}`) + } + return name +} + +export function nativeKind(file) { + const ext = path.extname(file).toLowerCase() + if (ext === '.c') return 'c' + if (ext === '.cc' || ext === '.cpp' || ext === '.cxx') return 'cxx' + if (ext === '.h' || ext === '.hh' || ext === '.hpp' || ext === '.hxx') return 'header' + throw new Error(`gea.sailfish.nativeSources files must be C, C++, or headers: ${file}`) +} diff --git a/targets/sailfish-os/prepare.mjs b/targets/sailfish-os/prepare.mjs index 56b6558..d36265d 100644 --- a/targets/sailfish-os/prepare.mjs +++ b/targets/sailfish-os/prepare.mjs @@ -4,6 +4,10 @@ import path from 'node:path' import { spawnSync } from 'node:child_process' import { fileURLToPath, pathToFileURL } from 'node:url' import sharp from 'sharp' +import { + appRelative, assertPackageVersion, frameworkPackageNames, nativeKind, + readPackageVersion, resolveGeastackPackage, shouldCopyStagedPath, validateLibraryName, +} from './prepare-lib.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const linuxRoot = path.resolve(here, '../..') @@ -13,16 +17,7 @@ if (!appArg || !['i486', 'armv7hl', 'aarch64'].includes(arch)) { process.exit(2) } const appDir = path.resolve(appArg) -const coreRepo = path.resolve(coreArg || path.join(linuxRoot, '../core')) -const corePackage = path.join(coreRepo, 'packages/core') -const npmCore = path.join(linuxRoot, 'node_modules/@geastack/core') -const coreMetadata = path.join(corePackage, 'package.json') -if (!fs.existsSync(coreMetadata)) throw new Error(`missing core package: ${coreMetadata}`) -const checkoutVersion = JSON.parse(fs.readFileSync(coreMetadata, 'utf8')).version -const installedVersion = JSON.parse(fs.readFileSync(path.join(npmCore, 'package.json'), 'utf8')).version -if (!checkoutVersion || checkoutVersion !== installedVersion) { - throw new Error(`core version mismatch: npm @geastack/core ${installedVersion}, checkout ${checkoutVersion}. Use a core checkout matching the installed npm package, or install the matching npm version.`) -} +const coreRepo = coreArg ? path.resolve(coreArg) : null const meta = JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')) const app = meta.gea if (!app?.id || !/^[a-z0-9][a-z0-9-]*$/.test(app.id)) throw new Error('gea.id must be a lowercase package identifier') @@ -39,6 +34,13 @@ if (typeof devicePixelRatio !== 'number' || !Number.isFinite(devicePixelRatio) | if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$/.test(organizationName)) throw new Error('gea.sailfish.organizationName must be a reverse-domain name') if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(applicationName)) throw new Error('gea.sailfish.applicationName must contain only letters, digits, and underscores') if (!Array.isArray(permissions) || !permissions.every((item) => typeof item === 'string' && /^[A-Za-z]+$/.test(item))) throw new Error('gea.sailfish.permissions must be an array of permission names') +const nativeSources = sailfish.nativeSources || [] +const includePaths = sailfish.includePaths || [] +const libraries = sailfish.libraries || [] +if (!Array.isArray(nativeSources) || !nativeSources.every((item) => typeof item === 'string')) throw new Error('gea.sailfish.nativeSources must be an array of app-relative paths') +if (!Array.isArray(includePaths) || !includePaths.every((item) => typeof item === 'string')) throw new Error('gea.sailfish.includePaths must be an array of app-relative directories') +if (!Array.isArray(libraries)) throw new Error('gea.sailfish.libraries must be an array of pkg-config module names') +for (const name of libraries) validateLibraryName(name) const npmVersion = meta.version || '0.1.0' const versionMatch = typeof npmVersion === 'string' && npmVersion.match(/^(\d+\.\d+\.\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/) if (!versionMatch) throw new Error('package.json version must be a valid x.y.z version with optional prerelease and build metadata') @@ -46,31 +48,47 @@ const rpmVersion = versionMatch[1] + (versionMatch[2] ? `~${versionMatch[2].repl const entry = app.entry || 'index.tsx' if (!fs.existsSync(path.join(appDir, entry))) throw new Error(`app entry missing: ${entry}`) const packageName = `harbour-gea-${app.id}` -const generated = path.join(here, 'generated', app.id) -const stage = path.join(here, 'build', `${app.id}-${arch}`, 'project') + +const npmPackages = Object.fromEntries(frameworkPackageNames().map((name) => [name, resolveGeastackPackage(appDir, linuxRoot, name)])) +const compilerDir = resolveGeastackPackage(appDir, linuxRoot, 'compiler') +const pluginDir = resolveGeastackPackage(appDir, linuxRoot, 'geatsc-plugin-gea') +if (coreRepo) { + for (const name of frameworkPackageNames()) { + const checkoutMeta = path.join(coreRepo, 'packages', name, 'package.json') + if (!fs.existsSync(checkoutMeta)) throw new Error(`missing @geastack/${name} checkout package: ${checkoutMeta}`) + assertPackageVersion(name, readPackageVersion(path.join(npmPackages[name], 'package.json')), readPackageVersion(checkoutMeta)) + } +} +const frameworkSource = (name) => coreRepo + ? path.join(coreRepo, 'packages', name) + : npmPackages[name] + +const workRoot = path.join(appDir, '.gea-sailfish') +const generated = path.join(workRoot, 'generated', app.id) +const stage = path.join(workRoot, 'build', `${app.id}-${arch}`, 'project') fs.mkdirSync(path.dirname(generated), { recursive: true }) -// The npm scripts/runtime and native checkout must have the same core version. -const args = [path.join(npmCore, 'scripts/build-gea-vite-geatsc.mjs'), +const args = [path.join(npmPackages.core, 'scripts/build-gea-vite-geatsc.mjs'), '--app-dir', appDir, '--entry', entry, '--out-dir', generated, - '--geatsc-bin', path.join(linuxRoot, 'node_modules/@geastack/compiler/dist/cli.js'), - '--geatsc-gea-plugin', path.join(linuxRoot, 'node_modules/@geastack/geatsc-plugin-gea/dist/index.js'), + '--geatsc-bin', path.join(compilerDir, 'dist/cli.js'), + '--geatsc-gea-plugin', path.join(pluginDir, 'dist/index.js'), '--font-viewport-width', '410', '--font-viewport-height', '502', '--font-device-pixel-ratio', String(devicePixelRatio), '--runtime-ttf-fonts'] -const result = spawnSync(process.execPath, args, { stdio: 'inherit', cwd: linuxRoot }) +const usingAppPackages = fs.existsSync(path.join(appDir, 'node_modules', '@geastack', 'core', 'package.json')) +const result = spawnSync(process.execPath, args, { stdio: 'inherit', cwd: usingAppPackages ? appDir : linuxRoot }) if (result.status !== 0) process.exit(result.status || 1) // sfdk mounts this project into its Linux build engine. Every compiled input // must therefore be inside this directory, with no Windows absolute paths. const resolvedStage = path.resolve(stage) -const allowedParent = path.resolve(here, 'build') + path.sep +const allowedParent = path.resolve(workRoot, 'build') + path.sep if (!resolvedStage.startsWith(allowedParent)) throw new Error('unsafe stage directory') fs.rmSync(stage, { recursive: true, force: true }) fs.mkdirSync(stage, { recursive: true }) -const copyTree = (from, to) => fs.cpSync(from, to, { recursive: true, - filter: (item) => !['.git', 'node_modules', 'dist', 'build', 'test', 'tests', '.build-test'].includes(path.basename(item)) }) -for (const name of ['core', 'host', 'engine', 'elements', 'geaos']) { - const source = path.join(coreRepo, 'packages', name) - if (!fs.existsSync(source)) throw new Error(`missing framework package: ${source}`) +const copyTree = (from, to, keepRoot = false) => fs.cpSync(from, to, { recursive: true, + filter: (item) => shouldCopyStagedPath(item, keepRoot ? from : '') }) +for (const name of frameworkPackageNames()) { + const source = frameworkSource(name) + if (!fs.existsSync(source)) throw new Error(`missing @geastack/${name}: ${source}`) copyTree(source, path.join(stage, 'framework', name)) } copyTree(generated, path.join(stage, 'generated')) @@ -78,15 +96,38 @@ copyTree(path.join(here, 'main'), path.join(stage, 'platform/main')) copyTree(path.join(here, 'include'), path.join(stage, 'platform/include')) fs.copyFileSync(path.join(here, 'CMakeLists.txt'), path.join(stage, 'CMakeLists.txt')) -const env = Object.fromEntries(['core', 'host', 'engine', 'elements', 'geaos'].map((name) => [ +const nativeRoot = path.join(stage, 'app_native') +const stagedNative = [] +for (const rel of nativeSources) { + const abs = appRelative(appDir, rel) + if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) throw new Error(`gea.sailfish.nativeSources file missing: ${rel}`) + nativeKind(rel) + const dest = path.join(nativeRoot, rel) + fs.mkdirSync(path.dirname(dest), { recursive: true }) + fs.copyFileSync(abs, dest) + stagedNative.push(rel.replaceAll('\\', '/')) +} +const stagedIncludes = [] +for (const rel of includePaths) { + const abs = appRelative(appDir, rel) + if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) throw new Error(`gea.sailfish.includePaths directory missing: ${rel}`) + const dest = path.join(nativeRoot, rel) + copyTree(abs, dest, true) + stagedIncludes.push(`app_native/${rel.replaceAll('\\', '/')}`) +} + +const env = Object.fromEntries(frameworkPackageNames().map((name) => [ ({ core: 'GEA_CORE', host: 'GEA_HOST_DIR', engine: 'GEA_ENGINE_DIR', elements: 'GEA_ELEMENTS_DIR', geaos: 'GEA_GEAOS_PACKAGE_DIR' })[name], path.join(stage, 'framework', name).replaceAll('\\', '/')])) -const { includeFlags, cSources, cxxSources } = await import(pathToFileURL(path.join(coreRepo, 'packages/core/gea_sources.mjs'))) -const rel = (file) => path.relative(stage, file).replaceAll('\\', '/') -const c = [...cSources(env).map(rel), 'platform/main/sailfish_apps.c'] -const cxx = cxxSources({ ...env, GEA_DESKTOP_WEBSOCKET_CURL: '1' }).filter((s) => !/\/(?:host\/camera|runtime|services\/[a-z_]+)\.cpp$/.test(s)).map(rel) +const sourcesModule = coreRepo + ? path.join(coreRepo, 'packages/core/gea_sources.mjs') + : path.join(npmPackages.core, 'gea_sources.mjs') +const { includeFlags, cSources, cxxSources } = await import(pathToFileURL(sourcesModule)) +const relStage = (file) => path.relative(stage, file).replaceAll('\\', '/') +const c = [...cSources(env).map(relStage), 'platform/main/sailfish_apps.c'] +const cxx = cxxSources({ ...env, GEA_DESKTOP_WEBSOCKET_CURL: '1' }).filter((s) => !/\/(?:host\/camera|runtime|services\/[a-z_]+)\.cpp$/.test(s)).map(relStage) if (!cxx.some((s) => s.endsWith('/host/websocket_curl.cpp'))) throw new Error('framework does not provide the native Linux WebSocket transport') -for (const name of ['display', 'audio', 'memory', 'network', 'sensors', 'storage', 'storage_bridge', 'timers', 'app_platform', 'main']) cxx.push(`platform/main/sailfish_${name}.cpp`) +for (const name of ['display', 'audio', 'memory', 'network', 'sensors', 'storage', 'storage_bridge', 'timers', 'app_platform', 'keyboard', 'main']) cxx.push(`platform/main/sailfish_${name}.cpp`) cxx.push('framework/core/gea_app_entry.cpp') for (const line of fs.readFileSync(path.join(generated, 'geatsc-sources.txt'), 'utf8').split(/\r?\n/).filter(Boolean)) { const file = path.resolve(line) @@ -96,12 +137,23 @@ for (const line of fs.readFileSync(path.join(generated, 'geatsc-sources.txt'), ' for (const name of ['gea_embedded_font_generated.cpp', 'gea_embedded_assets_generated.cpp']) { if (fs.existsSync(path.join(generated, name))) cxx.push(`generated/${name}`) } -const includes = ['platform/include', 'generated', ...includeFlags(env).map((s) => rel(s.slice(2)))] +for (const rel of stagedNative) { + const kind = nativeKind(rel) + const staged = `app_native/${rel}` + if (kind === 'c') c.push(staged) + else if (kind === 'cxx') cxx.push(staged) + const parent = path.posix.dirname(rel) + if (parent && parent !== '.') stagedIncludes.push(`app_native/${parent}`) +} +const includes = ['platform/include', 'generated', ...stagedIncludes, ...includeFlags(env).map((s) => relStage(s.slice(2)))] +if (stagedNative.length > 0) includes.push('app_native') for (const file of [...c, ...cxx]) if (!fs.existsSync(path.join(stage, file))) throw new Error(`source missing: ${file}`) const cmakeList = (key, values) => `set(${key}\n${values.map((s) => ` "${'${CMAKE_CURRENT_SOURCE_DIR}'}/${s}"`).join('\n')}\n)\n` +const cmakePlainList = (key, values) => `set(${key}\n${values.map((s) => ` "${s}"`).join('\n')}\n)\n` fs.writeFileSync(path.join(stage, 'sources.cmake'), `set(GEA_PACKAGE_NAME "${packageName}")\nset(GEA_APP_ID "${app.id}")\nset(GEA_APP_TITLE "${appName}")\nset(GEA_SAILFISH_ORGANIZATION_NAME "${organizationName}")\nset(GEA_SAILFISH_APPLICATION_NAME "${applicationName}")\n` + `set(GEA_SAILFISH_DEFAULT_DPR ${devicePixelRatio})\n` + + cmakePlainList('GEA_APP_LIBRARIES', libraries) + cmakeList('GEA_INCLUDE_DIRS', [...new Set(includes)]) + cmakeList('GEA_C_SOURCES', c) + cmakeList('GEA_CXX_SOURCES', cxx)) const icon = app.icons?.['512'] || app.icons?.['256'] || app.icons?.['128'] if (!icon || !fs.existsSync(path.join(appDir, icon))) throw new Error('app needs an icon source') @@ -112,6 +164,7 @@ for (const size of [86, 108, 128, 172]) { }).png().toFile(path.join(stage, 'icons', `${size}.png`)) } fs.writeFileSync(path.join(stage, `${packageName}.desktop`), `[Desktop Entry]\nType=Application\nName=${appName}\nExec=${packageName}\nIcon=${packageName}\nX-Nemo-Application-Type=generic\n\n[X-Sailjail]\nOrganizationName=${organizationName}\nApplicationName=${applicationName}\nPermissions=${[...new Set(permissions)].join(';')}\n`) +const libraryRequires = libraries.map((name) => `BuildRequires: pkgconfig(${name})\n`).join('') fs.mkdirSync(path.join(stage, 'rpm')) -fs.writeFileSync(path.join(stage, 'rpm', `${packageName}.spec`), `Name: ${packageName}\nVersion: ${rpmVersion}\nRelease: 1\nSummary: Gea ${appName} for Sailfish OS\nLicense: ${rpmLicense}\nBuildRequires: cmake\nBuildRequires: pkgconfig(sdl2)\nBuildRequires: pkgconfig(libcurl)\n\n%description\nGea JSX application for Sailfish OS.\n\n%build\n%cmake .\n%make_build\n\n%install\n%make_install\n\n%files\n%{_bindir}/${packageName}\n%{_datadir}/applications/${packageName}.desktop\n%{_datadir}/icons/hicolor/*/apps/${packageName}.png\n`) +fs.writeFileSync(path.join(stage, 'rpm', `${packageName}.spec`), `Name: ${packageName}\nVersion: ${rpmVersion}\nRelease: 1\nSummary: Gea ${appName} for Sailfish OS\nLicense: ${rpmLicense}\nBuildRequires: cmake\nBuildRequires: pkgconfig(sdl2)\nBuildRequires: pkgconfig(libcurl)\nBuildRequires: pkgconfig(glib-2.0)\nBuildRequires: pkgconfig(gobject-2.0)\nBuildRequires: pkgconfig(gio-2.0)\n${libraryRequires}\n%description\nGea JSX application for Sailfish OS.\n\n%build\n%cmake .\n%make_build\n\n%install\n%make_install\n\n%files\n%{_bindir}/${packageName}\n%{_datadir}/applications/${packageName}.desktop\n%{_datadir}/icons/hicolor/*/apps/${packageName}.png\n`) console.log(stage) diff --git a/targets/sailfish-os/prepare.test.mjs b/targets/sailfish-os/prepare.test.mjs new file mode 100644 index 0000000..27771c3 --- /dev/null +++ b/targets/sailfish-os/prepare.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { + appRelative, assertPackageVersion, nativeKind, resolveGeastackPackage, shouldCopyStagedPath, validateLibraryName, +} from './prepare-lib.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sailfish-prepare-')) +const app = path.join(root, 'app') +fs.mkdirSync(path.join(app, 'node_modules', '@geastack', 'core'), { recursive: true }) +fs.writeFileSync(path.join(app, 'node_modules', '@geastack', 'core', 'package.json'), '{"name":"@geastack/core","version":"1.2.3"}\n') + +assert.equal(resolveGeastackPackage(app, root, 'core'), path.join(app, 'node_modules', '@geastack', 'core')) +assert.throws(() => resolveGeastackPackage(app, root, 'engine'), /missing @geastack\/engine/) +assert.throws(() => assertPackageVersion('core', '1.2.3', '1.2.4'), /@geastack\/core version mismatch: app package 1.2.3, checkout 1.2.4/) +assert.throws(() => assertPackageVersion('host', '', '1.0.0'), /@geastack\/host version mismatch: app package missing/) +assert.doesNotThrow(() => assertPackageVersion('core', '1.2.3', '1.2.3')) + +assert.equal(appRelative(app, 'native/host.cpp'), path.join(app, 'native', 'host.cpp')) +assert.throws(() => appRelative(app, '../secret.cpp'), /stay inside the app/) +assert.throws(() => appRelative(app, path.resolve(app, 'native/host.cpp')), /must be relative/) +assert.equal(nativeKind('native/host.cpp'), 'cxx') +assert.equal(nativeKind('native/host.h'), 'header') +assert.equal(nativeKind('native/host.c'), 'c') +assert.throws(() => nativeKind('native/host.ts'), /must be C, C\+\+, or headers/) +assert.equal(validateLibraryName('openssl'), 'openssl') +assert.throws(() => validateLibraryName('pkg;rm'), /pkg-config module names/) + +const includeRoot = path.join(root, 'dist') +fs.mkdirSync(path.join(includeRoot, 'nested', 'dist'), { recursive: true }) +fs.writeFileSync(path.join(includeRoot, 'vendor.h'), 'vendor') +fs.writeFileSync(path.join(includeRoot, 'nested', 'inside.h'), 'inside') +fs.writeFileSync(path.join(includeRoot, 'nested', 'dist', 'skipped.h'), 'skip') +const copiedInclude = path.join(root, 'copied-include') +fs.cpSync(includeRoot, copiedInclude, { recursive: true, filter: (item) => shouldCopyStagedPath(item, includeRoot) }) +assert.equal(fs.readFileSync(path.join(copiedInclude, 'vendor.h'), 'utf8'), 'vendor') +assert.equal(fs.readFileSync(path.join(copiedInclude, 'nested', 'inside.h'), 'utf8'), 'inside') +assert.equal(fs.existsSync(path.join(copiedInclude, 'nested', 'dist')), false) +const frameworkDist = path.join(root, 'framework', 'dist') +assert.equal(shouldCopyStagedPath(frameworkDist), false) +assert.equal(shouldCopyStagedPath(path.join(root, 'framework', 'src', 'a.h')), true) + +const project = path.join(root, 'project') +fs.mkdirSync(project, { recursive: true }) +fs.writeFileSync(path.join(project, 'empty.o'), '') +fs.writeFileSync(path.join(project, 'text.o'), 'not an object') +fs.writeFileSync(path.join(project, 'real.o'), Buffer.from([0x7f, 0x45, 0x4c, 0x46, 1, 2, 3, 4])) +const complete = Buffer.alloc(52) +complete.set([0x7f, 0x45, 0x4c, 0x46, 1, 1, 1]) +complete.writeUInt16LE(1, 16) +complete.writeUInt16LE(3, 18) +complete.writeUInt32LE(1, 20) +complete.writeUInt16LE(52, 40) +fs.writeFileSync(path.join(project, 'complete.o'), complete) +const truncated = Buffer.alloc(52) +complete.copy(truncated) +truncated.writeUInt32LE(52, 32) +truncated.writeUInt16LE(40, 46) +truncated.writeUInt16LE(1, 48) +fs.writeFileSync(path.join(project, 'truncated.o'), truncated) +const recover = spawnSync(process.execPath, [path.join(here, 'recover-build.mjs'), project], { encoding: 'utf8' }) +assert.equal(recover.status, 0) +assert.equal(fs.existsSync(path.join(project, 'empty.o')), false) +assert.equal(fs.existsSync(path.join(project, 'text.o')), false) +assert.equal(fs.existsSync(path.join(project, 'real.o')), false) +assert.equal(fs.existsSync(path.join(project, 'truncated.o')), false) +assert.equal(fs.existsSync(path.join(project, 'complete.o')), true) +fs.mkdirSync(path.join(project, 'RPMS'), { recursive: true }) +fs.writeFileSync(path.join(project, 'RPMS', 'app.rpm'), 'rpm') +const cleaned = spawnSync(process.execPath, [path.join(here, 'recover-build.mjs'), project, '--clean'], { encoding: 'utf8' }) +assert.equal(cleaned.status, 0) +assert.equal(fs.existsSync(path.join(project, 'RPMS')), false) +assert.equal(fs.existsSync(path.join(project, 'real.o')), false) +assert.equal(fs.existsSync(path.join(project, 'complete.o')), false) + +fs.rmSync(root, { recursive: true, force: true }) +console.log('sailfish prepare tests passed') diff --git a/targets/sailfish-os/recover-build.mjs b/targets/sailfish-os/recover-build.mjs new file mode 100644 index 0000000..ed5a84e --- /dev/null +++ b/targets/sailfish-os/recover-build.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +// Drop object files an interrupted sfdk/cmake run left empty or truncated. +// sfdk otherwise treats a zero-byte .o as up to date and the link fails with +// undefined references. --clean also removes the staged RPM/CMake outputs. +import fs from 'node:fs' +import path from 'node:path' + +const args = process.argv.slice(2) +const clean = args.includes('--clean') +const projectArg = args.find((arg) => arg !== '--clean') +if (!projectArg) { + console.error('usage: node recover-build.mjs [--clean]') + process.exit(2) +} +const project = path.resolve(projectArg) +if (!fs.existsSync(project)) { + console.error(`staged project missing: ${project}`) + process.exit(1) +} + +function walk(dir, files) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '.git') continue + walk(full, files) + } else if (entry.isFile() && entry.name.endsWith('.o')) { + files.push(full) + } + } +} + +function readElfNumber(buf, offset, bytes, little) { + if (bytes === 2) return little ? buf.readUInt16LE(offset) : buf.readUInt16BE(offset) + if (bytes === 4) return little ? buf.readUInt32LE(offset) : buf.readUInt32BE(offset) + const value = little ? buf.readBigUInt64LE(offset) : buf.readBigUInt64BE(offset) + return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : -1 +} + +function elfTableFits(fileSize, offset, count, entrySize) { + if (offset < 0 || offset > fileSize) return false + if (count === 0) return true + if (entrySize <= 0) return false + return count <= Math.floor((fileSize - offset) / entrySize) +} + +function isElfObject(file) { + const stat = fs.statSync(file) + // A complete ELF header is 52 bytes (32-bit) or 64 bytes (64-bit). Magic alone is truncated. + if (!stat.isFile() || stat.size < 52) return false + const fd = fs.openSync(file, 'r') + try { + const header = Buffer.alloc(64) + const bytes = fs.readSync(fd, header, 0, Math.min(header.length, stat.size), 0) + if (header[0] !== 0x7f || header[1] !== 0x45 || header[2] !== 0x4c || header[3] !== 0x46) return false + const elfClass = header[4] + const elfData = header[5] + const little = elfData === 1 + if ((elfClass !== 1 && elfClass !== 2) || (elfData !== 1 && elfData !== 2) || header[6] !== 1) return false + const headerSize = elfClass === 2 ? 64 : 52 + if (bytes < headerSize) return false + if (readElfNumber(header, elfClass === 2 ? 52 : 40, 2, little) !== headerSize) return false + const wide = elfClass === 2 + const phoff = readElfNumber(header, wide ? 32 : 28, wide ? 8 : 4, little) + const shoff = readElfNumber(header, wide ? 40 : 32, wide ? 8 : 4, little) + const phentsize = readElfNumber(header, wide ? 54 : 42, 2, little) + const phnum = readElfNumber(header, wide ? 56 : 44, 2, little) + const shentsize = readElfNumber(header, wide ? 58 : 46, 2, little) + const shnum = readElfNumber(header, wide ? 60 : 48, 2, little) + return elfTableFits(stat.size, phoff, phnum, phentsize) && elfTableFits(stat.size, shoff, shnum, shentsize) + } finally { + fs.closeSync(fd) + } +} + +if (clean) { + for (const name of ['RPMS', 'SRPMS', 'BUILD', 'build']) { + fs.rmSync(path.join(project, name), { recursive: true, force: true }) + } + const cmakeDirs = [] + const visit = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (!entry.isDirectory()) continue + if (entry.name === 'CMakeFiles') cmakeDirs.push(full) + else if (entry.name !== 'node_modules' && entry.name !== '.git') visit(full) + } + } + visit(project) + for (const dir of cmakeDirs) fs.rmSync(dir, { recursive: true, force: true }) + for (const name of ['CMakeCache.txt', 'cmake_install.cmake', 'Makefile']) { + const file = path.join(project, name) + if (fs.existsSync(file)) fs.rmSync(file, { force: true }) + } +} + +const objects = [] +walk(project, objects) +let removed = 0 +for (const file of objects) { + if (clean || !isElfObject(file)) { + fs.rmSync(file, { force: true }) + removed += 1 + } +} +if (removed > 0 || clean) { + console.error(`[sailfish] ${clean ? 'cleaned staged build; ' : ''}removed ${removed} object file(s)`) +}