Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,35 @@ The entry point is `int main()` in `app/main.cpp`.
- **Space Grotesk (the primary UI font) has no Cyrillic**: in Russian the app
turns it off before `Application::init()` (`SwitchFontLoader::useBundledFont`,
a local borealis patch) so the UI is the system font throughout.
- **Two players (and engines) can be alive at once**: borealis runs a
`popActivity` callback *before* deleting the popped activity, so the next
episode's `MpvView` is built before the previous one's destructor. Anything
process-wide a player touches must be counted or keyed, not toggled: the
keep-awake flag (`keepAwake`), the engine's cache file (a slot per live
engine in `torrentfs.c`), DHT targets (keyed by hash *and* owner).
- **Leave the player through `popPlayerThen`**: the settings panel is a
translucent activity, so the player keeps drawing — and reaching the end of
the file — under it. Counting pops from the top of the stack closed only the
panel.
- **A screen under another activity must not free its children**: the focus
stack holds a raw pointer to the view that opened the screen above. Late
results wait until `ui::isOnTop(this)` (Home's Continue row, Stremio
catalogs, the sources list). A rebuilt row gets a fresh `alive` token so
artwork landing for an old card is dropped.
- **Long work goes to `workers::run`, not `brls::async`**: borealis has one
task thread, and the library/history saves queue on it. An engine open, a
magnet resolve, the update download all run on the pool and take a cancel
flag; `torrent_meta_abort_all()` and `http::abortAll()` end them as the app
quits (`getExitEvent`), and `library::flush()` writes the save borealis
drops from its queue (`getExitDoneEvent`).
- **Saved files go through `fsutil::writeAtomic` / `readFirst`**
(`app/fsutil.hpp`): .tmp + rename with every write checked, the previous
version kept as .bak, and a reader that falls back to them when a file is
missing or cut short. Writing a data file in place lost it to a power cut.
- `http::get` succeeds on any HTTP answer: pass `status` (or use the Stremio
`getJson`) and treat non-2xx as a failure before caching anything.
- `http::get`/`postJson` reuse one curl handle per thread (kept connections);
`http::shutdown()` closes them in `main()` once every thread is joined.

## Debugging on device

Expand Down
11 changes: 10 additions & 1 deletion app/browse.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1202,8 +1202,17 @@ void bindLibraryToggle(brls::View* host, const std::string& authKey,
// The footer follows immediately -- inLibrary has already flipped,
// the write goes on in the background -- and only moves back if the
// account refuses it.
// The answer comes after two round trips: B may have closed the
// screen by then. `v` is touched only while it is still the
// focus or one of its ancestors -- the focused chain is alive.
auto repaint = [v, hint, id = item.id]() {
v->updateActionHint(brls::BUTTON_X, hint(id));
for (brls::View* f = brls::Application::getCurrentFocus(); f;
f = f->getParent())
if (f == v)
{
v->updateActionHint(brls::BUTTON_X, hint(id));
break;
}
brls::Application::getGlobalHintsUpdateEvent()->fire();
};
stremio::setLibraryMemberAsync(
Expand Down
192 changes: 112 additions & 80 deletions app/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include <string>

#include "appdata.hpp"
#include "fsutil.hpp"
#include "i18n.hpp"

namespace config
Expand Down Expand Up @@ -60,9 +61,62 @@ std::string readStr(const std::string& body, const char* key,
if (k == std::string::npos) return dflt;
size_t q1 = body.find('"', body.find(':', k + pat.size()));
if (q1 == std::string::npos) return dflt;
size_t q2 = body.find('"', q1 + 1);
if (q2 == std::string::npos) return dflt;
return body.substr(q1 + 1, q2 - q1 - 1);
// What jsonStr() escapes: \" \\ and \u00XX. Anything else stays as
// written, so a value saved before escaping existed reads back the same.
std::string out;
for (size_t i = q1 + 1; i < body.size(); i++)
{
char c = body[i];
if (c == '"') return out;
if (c == '\\' && i + 1 < body.size())
{
char e = body[i + 1];
if (e == '"' || e == '\\')
{
out += e;
i++;
continue;
}
if (e == 'u' && i + 5 < body.size() && body.compare(i + 2, 2, "00") == 0)
{
char* end = nullptr;
std::string hex = body.substr(i + 4, 2);
long v = std::strtol(hex.c_str(), &end, 16);
if (end && *end == 0)
{
out += (char)v;
i += 5;
continue;
}
}
}
out += c;
}
return dflt; // no closing quote
}

// `s` as a JSON string: quotes, backslashes and control characters escaped
// (a value with a quote in it used to break the whole file).
std::string jsonStr(const std::string& s)
{
std::string o = "\"";
for (unsigned char c : s)
{
if (c == '"' || c == '\\')
{
o += '\\';
o += (char)c;
}
else if (c < 0x20)
{
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", c);
o += buf;
}
else
o += (char)c;
}
return o + "\"";
}

// The languages offered, and what each maps to for mpv. Both 639-2 spellings
Expand Down Expand Up @@ -280,20 +334,16 @@ const std::vector<std::string>& uiWidthLabels()

void load()
{
FILE* f = std::fopen(kPath, "rb");
if (!f)
// config.json, or after a cut save its .tmp / the version before.
std::string body;
if (!fsutil::readFirst(kPath, body,
[](const std::string& b) { return fsutil::looksComplete(b); }))
{
brls::Logger::info("[config] no config.json, using defaults");
defaultTmdbProxy();
return;
}

std::string body;
char buf[512];
size_t n;
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) body.append(buf, n);
std::fclose(f);

std::string sTab = readStr(body, "startupTab", "home");
cfg.startupTab = (sTab == "stremio") ? Tab::STREMIO :
(sTab == "jackett") ? Tab::JACKETT :
Expand Down Expand Up @@ -374,82 +424,64 @@ void load()

bool save()
{
FILE* f = std::fopen(kPath, "wb");
if (!f)
{
brls::Logger::warning("[config] cannot write {}", kPath);
return false;
}
const char* sTabStr = (cfg.startupTab == Tab::STREMIO) ? "stremio" :
(cfg.startupTab == Tab::JACKETT) ? "jackett" :
(cfg.startupTab == Tab::HISTORY) ? "history" :
(cfg.startupTab == Tab::HOME) ? "home" :
(cfg.startupTab == Tab::BOOKMARKS) ? "bookmarks" : "local";
std::string o = "{\n";
auto kv = [&o](const char* key, const std::string& value) {
o += " \"";
o += key;
o += "\": ";
o += value;
o += ",\n";
};
auto b = [](bool v) { return std::string(v ? "true" : "false"); };
auto n = [](int v) { return std::to_string(v); };
kv("configVersion", "2");
kv("bufferMode", n(cfg.bufferMode));
kv("startupTab", jsonStr(sTabStr));
kv("jackettUrl", jsonStr(cfg.jackettUrl));
kv("jackettApiKey", jsonStr(cfg.jackettApiKey));
kv("jackettSetupAsked", b(cfg.jackettSetupAsked));
kv("torrServerUrl", jsonStr(cfg.torrServerUrl));
kv("proxyUrl", jsonStr(cfg.proxyUrl));
kv("tmdbApiKey", jsonStr(cfg.tmdbApiKey));
kv("tmdbApiUrl", jsonStr(cfg.tmdbApiUrl));
kv("tmdbImageUrl", jsonStr(cfg.tmdbImageUrl));
kv("tmdbProxyUrl", jsonStr(cfg.tmdbProxyUrl));
// Only a choice the user made is kept: otherwise the proxy keeps following
// the UI language.
const std::string proxyLine =
cfg.tmdbProxyUserSet
? std::string(" \"tmdbProxyEnabled\": ") +
(cfg.tmdbProxyEnabled ? "true" : "false") + ",\n"
: std::string();
std::fprintf(f,
"{\n"
" \"configVersion\": 2,\n"
" \"bufferMode\": %d,\n"
" \"startupTab\": \"%s\",\n"
" \"jackettUrl\": \"%s\",\n"
" \"jackettApiKey\": \"%s\",\n"
" \"jackettSetupAsked\": %s,\n"
" \"torrServerUrl\": \"%s\",\n"
" \"proxyUrl\": \"%s\",\n"
" \"tmdbApiKey\": \"%s\",\n"
" \"tmdbApiUrl\": \"%s\",\n"
" \"tmdbImageUrl\": \"%s\",\n"
" \"tmdbProxyUrl\": \"%s\",\n"
"%s"
" \"logging\": %s,\n"
" \"hide4k\": %s,\n"
" \"rateGovernor\": %s,\n"
" \"ramStream\": %s,\n"
" \"checkUpdates\": %s,\n"
" \"audioLang\": \"%s\",\n"
" \"subLang\": \"%s\",\n"
" \"subtitles\": %s,\n"
" \"hwDecode\": %s,\n"
" \"audioBoost\": %s,\n"
" \"picBrightness\": %d,\n"
" \"picContrast\": %d,\n"
" \"picGamma\": %d,\n"
" \"picSaturation\": %d,\n"
" \"dockedUiWidth\": %d,\n"
" \"handheldUiWidth\": %d,\n"
" \"language\": \"%s\",\n"
" \"accent\": \"%s\",\n"
" \"themeVariant\": \"%s\",\n"
" \"listStyle\": \"%s\"\n"
"}\n",
cfg.bufferMode, sTabStr,
cfg.jackettUrl.c_str(),
cfg.jackettApiKey.c_str(),
cfg.jackettSetupAsked ? "true" : "false",
cfg.torrServerUrl.c_str(),
cfg.proxyUrl.c_str(),
cfg.tmdbApiKey.c_str(),
cfg.tmdbApiUrl.c_str(),
cfg.tmdbImageUrl.c_str(),
cfg.tmdbProxyUrl.c_str(),
proxyLine.c_str(),
cfg.logging ? "true" : "false", cfg.hide4k ? "true" : "false",
cfg.rateGovernor ? "true" : "false",
cfg.ramStream ? "true" : "false",
cfg.checkUpdates ? "true" : "false", cfg.audioLang.c_str(),
cfg.subLang.c_str(), cfg.subtitles ? "true" : "false",
cfg.hwDecode ? "true" : "false",
cfg.audioBoost ? "true" : "false", cfg.picBrightness,
cfg.picContrast, cfg.picGamma, cfg.picSaturation, cfg.dockedUiWidth,
cfg.handheldUiWidth, cfg.language.c_str(), cfg.accent.c_str(),
cfg.themeVariant.c_str(), cfg.listStyle.c_str());
std::fclose(f);
if (cfg.tmdbProxyUserSet) kv("tmdbProxyEnabled", b(cfg.tmdbProxyEnabled));
kv("logging", b(cfg.logging));
kv("hide4k", b(cfg.hide4k));
kv("rateGovernor", b(cfg.rateGovernor));
kv("ramStream", b(cfg.ramStream));
kv("checkUpdates", b(cfg.checkUpdates));
kv("audioLang", jsonStr(cfg.audioLang));
kv("subLang", jsonStr(cfg.subLang));
kv("subtitles", b(cfg.subtitles));
kv("hwDecode", b(cfg.hwDecode));
kv("audioBoost", b(cfg.audioBoost));
kv("picBrightness", n(cfg.picBrightness));
kv("picContrast", n(cfg.picContrast));
kv("picGamma", n(cfg.picGamma));
kv("picSaturation", n(cfg.picSaturation));
kv("dockedUiWidth", n(cfg.dockedUiWidth));
kv("handheldUiWidth", n(cfg.handheldUiWidth));
kv("language", jsonStr(cfg.language));
kv("accent", jsonStr(cfg.accent));
kv("themeVariant", jsonStr(cfg.themeVariant));
o += " \"listStyle\": " + jsonStr(cfg.listStyle) + "\n}\n";

// Atomic: rewritten in place, a power cut mid-save left an empty file --
// the Jackett address and key, the TMDB key and every setting gone.
if (!fsutil::writeAtomic(kPath, o))
{
brls::Logger::warning("[config] cannot write {}", kPath);
return false;
}
return true;
}

Expand Down
84 changes: 84 additions & 0 deletions app/fsutil.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#pragma once

// Small file helpers for the app's own data (config, library, history,
// lists). Header-only: the PC test builds (pctest/run_tests.sh) compile a
// few app files on their own and need nothing extra to link.

#include <cstdio>
#include <functional>
#include <string>

namespace fsutil
{

// The whole file into `out`. False if it cannot be opened.
inline bool readFile(const std::string& path, std::string& out)
{
FILE* f = std::fopen(path.c_str(), "rb");
if (!f) return false;
char buf[8192];
size_t n;
out.clear();
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n);
std::fclose(f);
return true;
}

// Replaces `path` with `data` so that a power cut or a full SD card never
// leaves a truncated file behind: written to path.tmp and checked (fwrite,
// fflush, fclose), then renamed in. The previous version stays as path.bak.
// False on any failure, with the old file still in place.
inline bool writeAtomic(const std::string& path, const std::string& data)
{
std::string tmp = path + ".tmp";
FILE* f = std::fopen(tmp.c_str(), "wb");
if (!f) return false;
bool ok = std::fwrite(data.data(), 1, data.size(), f) == data.size();
ok = std::fflush(f) == 0 && ok;
ok = std::fclose(f) == 0 && ok;
if (!ok)
{
std::remove(tmp.c_str());
return false;
}
// FAT cannot rename over an existing file: move the old one aside first.
// A cut between the two renames leaves path missing and path.tmp whole,
// which readFirst() then picks up.
std::string bak = path + ".bak";
std::remove(bak.c_str());
bool hadOld = std::rename(path.c_str(), bak.c_str()) == 0;
if (std::rename(tmp.c_str(), path.c_str()) != 0)
{
if (hadOld) std::rename(bak.c_str(), path.c_str());
return false;
}
return true;
}

// The first of path, path.tmp (a save cut between its renames) and path.bak
// (the version before the last save) that opens and passes `valid` (when
// given). False if none does.
inline bool readFirst(const std::string& path, std::string& out,
const std::function<bool(const std::string&)>& valid = nullptr)
{
const std::string candidates[] = { path, path + ".tmp", path + ".bak" };
for (const auto& p : candidates)
if (readFile(p, out) && (!valid || valid(out))) return true;
out.clear();
return false;
}

// A JSON document that was written to the end: its last non-space character
// closes it. A file cut short by a lost write fails this.
inline bool looksComplete(const std::string& body, char last = '}')
{
for (size_t i = body.size(); i > 0; i--)
{
char c = body[i - 1];
if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue;
return c == last;
}
return false;
}

} // namespace fsutil
Loading
Loading