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
38 changes: 38 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Contributing to Blobling

Ideas, bug reports and pull requests are welcome. Ideas and the roadmap live in
the [issues](https://github.com/mLampir/blobling/issues); how the game works is
in [docs/gameplay.md](docs/gameplay.md), and the notes in [CLAUDE.md](CLAUDE.md)
list the rules the project keeps (English only, texts in the language files,
sprites from the script, and so on).

Before you open a pull request, run the tests and the type check:

```bash
npm test && npm run check
```

## The license of your contribution

Blobling is licensed under the GNU General Public License, version 2 or (at
your option) any later version ([LICENSE](LICENSE)). The phone app is also
offered in Apple's App Store and Google Play, whose terms of use add
restrictions the GPL alone does not allow. So the project's own code carries
one additional permission:

> **App store permission.** As an additional permission to the GNU General
> Public License, the copyright holders of Blobling allow Lampir
> (<https://lampir.at>) to distribute the Blobling app, built from this
> code, through app stores (such as Apple's App Store and Google Play) under
> the terms of use those stores require, even where those terms restrict the
> recipients' rights under the GPL. The source code stays available under the
> GPL in this repository.

**By opening a pull request, you agree that your contribution is licensed
under the GPL version 2 or later together with this permission**, and that you
have the right to license it that way (it is your own work, or under a license
that allows it). Please don't submit code you copied from elsewhere unless you
say where it comes from and under which license.

The sprites in `app/pet/assets/` are under their own license
([app/pet/assets/LICENSE.txt](app/pet/assets/LICENSE.txt)).
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@ npm run package -- --version v1.2.0 --repo me/fork # also dist/install.sh for
| Path | Purpose |
|---|---|
| `setup.sh` | Sets up the Pi completely (`--app`: update the app only) |
| `CONTRIBUTING.md` | How to contribute, and the license (with the app store permission) a contribution comes under |
| `core/` | The pet: game logic, scene and sprites (`src/`) and their tests (`test/`). It runs anywhere and knows nothing about files or the panel; `api.ts` is the pet's JSON API, `weather.ts` asks Open-Meteo, `notify.ts` sees its next need coming for the app's notifications |
| `pi/src/main.ts` | Entry point on the Pi: panel driver, display loop, web server |
| `pi/src/display.ts` | The display loop (pet, off, brightness, night mode) |
Expand All @@ -495,7 +496,7 @@ npm run package -- --version v1.2.0 --repo me/fork # also dist/install.sh for
| `app/android/`, `app/ios/`, `capacitor.config.json` | The app's projects for the stores (Capacitor); `tools/app_icons.ts` draws their icons |
| `app/templates/` | The web interface (settings page) |
| `app/locales/` | All texts of the interface, one file per language |
| `docs/privacy.md`, `docs/support.md` (each with a `.de.md`), `docs/_config.yml` | The app's privacy policy and support page for the stores, and the settings of the GitHub Pages site that serves them |
| `docs/privacy.md`, `docs/support.md` (each with a `.de.md`), `docs/licenses.md`, `docs/_config.yml` | The app's privacy policy, support page and the licenses of the parts it is built with, linked from *More* in the app, and the settings of the GitHub Pages site that serves them |
| `app/pet/assets/` | The sprite sheets |
| `app/pet/sounds/` | The app's sounds (WAV), written by `npm run sounds` from the recipes in `tools/sfx.ts`; don't edit them by hand |
| `systemd/` | The service, and the update unit the button starts |
Expand All @@ -519,7 +520,8 @@ Copyright (C) 2026 Lampir (<https://lampir.at>)

- **Code:** GNU General Public License, version 2 or (at your option) any
later version, see [LICENSE](LICENSE). The project comes without any
warranty.
warranty. Contributions come with an additional permission that lets the
app be offered in the app stores, see [CONTRIBUTING.md](CONTRIBUTING.md).
- **Sprites** in `app/pet/assets/`:
[CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/), see
[app/pet/assets/LICENSE.txt](app/pet/assets/LICENSE.txt). Use, change and
Expand Down
1 change: 1 addition & 0 deletions app/android/app/capacitor.build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-filesystem')
implementation project(':capacitor-geolocation')
implementation project(':capacitor-haptics')
implementation project(':capacitor-local-notifications')

Expand Down
13 changes: 9 additions & 4 deletions app/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/AppTheme">
android:theme="@style/AppTheme"
android:localeConfig="@xml/locales_config">
<!-- usesCleartextTraffic: a Pi on the home network answers over plain HTTP (app/src/remote.ts) -->
<!-- localeConfig: the app's language can be chosen in Android's settings (13 and later), like
any app's. No "locale" in configChanges below: choosing one restarts the app, and the
page comes back in the new language. -->

<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
Expand All @@ -36,9 +40,10 @@
<!-- Permissions -->

<uses-permission android:name="android.permission.INTERNET" />
<!-- "Use my location" for the weather; the app keeps it to two decimals (about a kilometre) -->
<!-- "Use my location" for the weather; the app keeps it to two decimals (about a kilometre).
Approximate only (app/src/native.ts): no ACCESS_FINE_LOCATION, so the dialog never offers
or asks again for the precise one. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- The short buzz with the pet's answers (app/src/haptics.ts); granted at install, no dialog -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- The pet calls when it needs somebody (app/src/notify.ts); asked for after its first meal -->
Expand Down
6 changes: 6 additions & 0 deletions app/android/app/src/main/res/xml/locales_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The languages of app/locales/, for the app's language in Android's settings (AndroidManifest.xml) -->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="de" />
<locale android:name="en" />
</locale-config>
3 changes: 3 additions & 0 deletions app/android/capacitor.settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../../node_modules/@capacit
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/android')

include ':capacitor-geolocation'
project(':capacitor-geolocation').projectDir = new File('../../node_modules/@capacitor/geolocation/android')

include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../../node_modules/@capacitor/haptics/android')

Expand Down
4 changes: 4 additions & 0 deletions app/ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
B10B1100B10B1100B10B1201 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B10B1100B10B1100B10B1202 /* PrivacyInfo.xcprivacy */; };
B10B1100B10B1100B10B1101 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = B10B1100B10B1100B10B1102 /* InfoPlist.strings */; };
/* End PBXBuildFile section */

Expand All @@ -31,6 +32,7 @@
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
B10B1100B10B1100B10B1202 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
B10B1100B10B1100B10B1103 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -74,6 +76,7 @@
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
B10B1100B10B1100B10B1102 /* InfoPlist.strings */,
B10B1100B10B1100B10B1202 /* PrivacyInfo.xcprivacy */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
Expand Down Expand Up @@ -153,6 +156,7 @@
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
B10B1100B10B1100B10B1101 /* InfoPlist.strings in Resources */,
B10B1100B10B1100B10B1201 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
5 changes: 5 additions & 0 deletions app/ios/App/App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
<string>$(CAPACITOR_DEBUG)</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>de</string>
</array>
<key>CFBundleDisplayName</key>
<string>Blobling</string>
<key>CFBundleExecutable</key>
Expand Down
42 changes: 42 additions & 0 deletions app/ios/App/App/PrivacyInfo.xcprivacy
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!--
The app's privacy manifest, which App Store Connect checks on upload. No tracking. The one kind
of data that leaves the phone is the rounded location for the weather (docs/privacy.md, 3).
The save game is written with @capacitor/filesystem, which reads file dates in the app's own
container (reason C617.1).
-->
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeCoarseLocation</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
</dict>
</plist>
2 changes: 2 additions & 0 deletions app/ios/App/CapApp-SPM/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.3"),
.package(name: "CapacitorFilesystem", path: "../../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorGeolocation", path: "../../../../node_modules/@capacitor/geolocation"),
.package(name: "CapacitorHaptics", path: "../../../../node_modules/@capacitor/haptics"),
.package(name: "CapacitorLocalNotifications", path: "../../../../node_modules/@capacitor/local-notifications")
],
Expand All @@ -23,6 +24,7 @@ let package = Package(
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorGeolocation", package: "CapacitorGeolocation"),
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications")
]
Expand Down
4 changes: 4 additions & 0 deletions app/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,10 @@
"location_set": "Wetter für {latitude}, {longitude}.",
"locate_failed": "Dein Ort ließ sich nicht herausfinden.",
"connect_note": "Blobling gibt es auch als LED-Display zum Selberbauen.",
"privacy": "Datenschutz",
"support": "Hilfe",
"licenses": "Lizenzen",
"version": "Version {version}",
"connect": "Mit dem Display verbinden",
"connect_prompt": "Adresse des Displays, z. B. raspberrypi.local oder blobling.local",
"connect_ok": "Verbinden",
Expand Down
4 changes: 4 additions & 0 deletions app/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,10 @@
"location_set": "Weather for {latitude}, {longitude}.",
"locate_failed": "Couldn't find out where you are.",
"connect_note": "Blobling also comes as an LED display to build yourself.",
"privacy": "Privacy policy",
"support": "Help",
"licenses": "Licenses",
"version": "Version {version}",
"connect": "Connect to the display",
"connect_prompt": "Address of the display, e.g. raspberrypi.local or blobling.local",
"connect_ok": "Connect",
Expand Down
11 changes: 11 additions & 0 deletions app/pet.html
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ <h2 data-t="page.tabs.more"></h2>
<button class="wide" id="update-start" data-t="page.app.update" hidden></button>
<button class="wide quiet" id="disconnect" data-t="page.app.disconnect"></button>
</div>
<p class="note" id="legal" hidden><a id="privacy-link" target="_blank" rel="noopener" data-t="page.app.privacy"></a> · <a id="support-link" target="_blank" rel="noopener" data-t="page.app.support"></a> · <a id="licenses-link" target="_blank" rel="noopener" data-t="page.app.licenses"></a><span id="version"></span></p>
</div>
</section>

Expand Down Expand Up @@ -2211,6 +2212,16 @@ <h2 data-t="page.tabs.more"></h2>
$("build-link").hidden = $("connect").hidden = !!app.connectedTo;
$("disconnect").hidden = $("offline-leave").hidden = !app.connectedTo;
}
// The app from the stores links its privacy policy and help (docs/ on GitHub Pages), as the stores ask
if (app.native) {
const docs = "https://mlampir.github.io/blobling/";
const de = LANG.startsWith("de") ? ".de" : "";
$("privacy-link").href = `${docs}privacy${de}`;
$("support-link").href = `${docs}support${de}`;
$("licenses-link").href = `${docs}licenses`;
if (app.version) $("version").textContent = ` · ${t("page.app.version", { version: app.version })}`;
$("legal").hidden = false;
}
// The Pi's own page shows the display part only for its update
if (!app.native) $("display-note").hidden = $("connect").hidden = $("disconnect").hidden = true;

Expand Down
2 changes: 2 additions & 0 deletions app/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ const app = {
mode,
/** The page is the app from the stores. */
native,
/** The app's version in a build for the stores, "" otherwise. */
version: __APP_VERSION__,
/** The Pi's address, if the app is connected to one. */
connectedTo,
/** All texts in the page's language, {key: text}. */
Expand Down
26 changes: 12 additions & 14 deletions app/src/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,20 @@
*/

import { Capacitor } from "@capacitor/core";
import { Geolocation } from "@capacitor/geolocation";

/** Whether the page runs inside the app from the stores. */
export const native = Capacitor.isNativePlatform();

/** Where the phone is, as [latitude, longitude]. Throws if it can't tell (or may not). */
export function locate(): Promise<[number, number]> {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error("no geolocation"));
return;
}
// coarse is enough for the weather, and quicker
navigator.geolocation.getCurrentPosition(
({ coords }) => resolve([coords.latitude, coords.longitude]),
(error) => reject(new Error(error.message)),
{ enableHighAccuracy: false, timeout: 15_000, maximumAge: 3600_000 },
);
});
/**
* Where the phone is, as [latitude, longitude]. Throws if it can't tell (or may not).
*
* In the app through Capacitor's Geolocation, not the WebView's navigator.geolocation: on iOS the
* WebView would ask a second time on its own ("localhost would like to use your location"), and
* on Android it would also ask for the precise location. Approximate is enough for the weather.
*/
export async function locate(): Promise<[number, number]> {
const { coords } = await Geolocation.getCurrentPosition(
{ enableHighAccuracy: false, timeout: 15_000, maximumAge: 3600_000 });
return [coords.latitude, coords.longitude];
}
3 changes: 3 additions & 0 deletions app/src/virtual.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ declare module "blobling:sounds" {
const sounds: Record<string, string>;
export default sounds;
}

/** The app's version ("1.2.0") in a build for the stores, "" otherwise (tools/build.mjs). */
declare const __APP_VERSION__: string;
22 changes: 22 additions & 0 deletions app/test/languages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// The phone's settings offer the app's languages from two lists of their own; both have to name
// every language file in app/locales/.

import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import { test } from "node:test";

const files = readdirSync("app/locales").filter((name) => name.endsWith(".json"))
.map((name) => name.slice(0, -".json".length)).sort();

test("Android's locales_config.xml names every language file", () => {
const config = readFileSync("app/android/app/src/main/res/xml/locales_config.xml", "utf-8");
const names = [...config.matchAll(/<locale android:name="([\w-]+)"/g)].map((match) => match[1]).sort();
assert.deepEqual(names, files);
});

test("iOS's CFBundleLocalizations names every language file", () => {
const plist = readFileSync("app/ios/App/App/Info.plist", "utf-8");
const list = plist.match(/<key>CFBundleLocalizations<\/key>\s*<array>([\s\S]*?)<\/array>/)?.[1] ?? "";
const names = [...list.matchAll(/<string>([\w-]+)<\/string>/g)].map((match) => match[1]).sort();
assert.deepEqual(names, files);
});
Loading
Loading