Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ jobs:
- name: 🔍 Run linter
run: bun run lint

- name: 📜 Check open-source license manifest
run: bun run licenses:check

typecheck:
runs-on: ubuntu-latest

Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
"main": "expo-router/entry",
"private": true,
"scripts": {
"postinstall": "bun run licenses:generate",
"prestart": "bun run licenses:generate",
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"i18n:extract": "lingui extract",
"i18n:compile": "lingui compile",
"i18n:check": "bun run i18n:extract && bun run i18n:compile && git diff --exit-code -- src/locales",
"licenses:check": "bun run licenses:generate && git diff --exit-code -- src/generated/open-source-licenses.ts",
"licenses:generate": "bun scripts/generate-open-source-licenses.mjs",
"test": "jest",
"lint": "expo lint",
"clean-ios": "watchman watch-del-all && rm -fr $TMPDIR/haste-map-* && rm -rf $TMPDIR/metro-cache && bunx expo prebuild --clean && bunx expo run:ios --device --no-build-cache",
Expand Down
140 changes: 140 additions & 0 deletions scripts/generate-open-source-licenses.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const projectPackagePath = join(projectRoot, "package.json");
const outputPath = join(projectRoot, "src/generated/open-source-licenses.ts");

function readPackage(packagePath) {
return JSON.parse(readFileSync(packagePath, "utf8"));
}

function findDependencyPackage(name, fromDirectory) {
let directory = fromDirectory;

while (directory.startsWith(projectRoot)) {
const candidate = join(directory, "node_modules", name, "package.json");
if (existsSync(candidate)) {
return candidate;
}

const parent = dirname(directory);
if (parent === directory) {
break;
}
directory = parent;
}

return null;
}

function repositoryUrl(packageJson) {
const repository =
typeof packageJson.repository === "string"
? packageJson.repository
: packageJson.repository?.url;
const rawUrl = packageJson.homepage ?? repository;

if (typeof rawUrl !== "string") {
return null;
}

return rawUrl
.replace(/^git\+/, "")
.replace(/^git:\/\//, "https://")
.replace(/\.git(#.*)?$/, "$1");
}

function licenseText(packageDirectory, packageJson) {
const licenseFile = readdirSync(packageDirectory)
.filter((fileName) =>
/^(licen[cs]e|copying|notice)(\..*)?$/i.test(fileName),
)
.sort((left, right) => {
const priority = (fileName) => {
if (/^licen[cs]e(\..*)?$/i.test(fileName)) {
return 0;
}
if (/^copying(\..*)?$/i.test(fileName)) {
return 1;
}
return 2;
};

return priority(left) - priority(right) || left.localeCompare(right);
})[0];

if (licenseFile) {
return readFileSync(join(packageDirectory, licenseFile), "utf8").trim();
}

return `This package declares the ${typeof packageJson.license === "string" ? packageJson.license : "unspecified"} license. The full license text was not included in its installed package distribution.`;
}

const rootPackage = readPackage(projectPackagePath);
const pending = Object.keys(rootPackage.dependencies ?? {}).map((name) => ({
fromDirectory: projectRoot,
name,
}));
const visited = new Set();
const licenses = [];

while (pending.length > 0) {
const dependency = pending.pop();
const packagePath = findDependencyPackage(
dependency.name,
dependency.fromDirectory,
);

if (!packagePath) {
console.warn(`Unable to resolve ${dependency.name}`);
continue;
}

const packageJson = readPackage(packagePath);
const identity = `${packageJson.name}@${packageJson.version}`;
if (visited.has(identity)) {
continue;
}
visited.add(identity);

licenses.push({
id: identity,
license:
typeof packageJson.license === "string"
? packageJson.license
: "See package distribution",
licenseText: licenseText(dirname(packagePath), packageJson),
name: packageJson.name,
url: repositoryUrl(packageJson),
version: packageJson.version,
});

const packageDirectory = dirname(packagePath);
for (const name of Object.keys(packageJson.dependencies ?? {})) {
pending.push({ fromDirectory: packageDirectory, name });
}
}

licenses.sort((left, right) => left.name.localeCompare(right.name));

const generatedSource = `// Generated by scripts/generate-open-source-licenses.mjs.
// Do not edit manually.
/* eslint-disable prettier/prettier, comma-dangle */

export type OpenSourceLicense = {
id: string;
license: string;
licenseText: string;
name: string;
url: string | null;
version: string;
};

export const OPEN_SOURCE_LICENSES = ${JSON.stringify(licenses, null, 2)} as const satisfies readonly OpenSourceLicense[];
`;

mkdirSync(dirname(outputPath), { recursive: true });
await Bun.write(outputPath, generatedSource);
console.log(`Generated ${licenses.length} license entries.`);
29 changes: 29 additions & 0 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as Linking from "expo-linking";
import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from "expo-router";
import { useEffect, useRef } from "react";
import {
Appearance,
AppState,
AppStateStatus,
Platform,
Expand Down Expand Up @@ -62,9 +63,16 @@ function RootLayoutContent() {
(state) => state.hasCompletedOnboarding,
);
const hasHydratedSettings = useSettingsStore((state) => state.hasHydrated);
const themePreference = useSettingsStore((state) => state.themePreference);
const handleTokenUri = useHandleTokenUri();
const { pollChallenges } = useChallengePolling();

useEffect(() => {
Appearance.setColorScheme(
themePreference === "automatic" ? "unspecified" : themePreference,
);
}, [themePreference]);

const theme = useTheme();
const tabBarBackgroundColor = theme.background;
const statusBarStyle =
Expand Down Expand Up @@ -175,6 +183,27 @@ function RootLayoutContent() {
) : undefined,
}}
/>
<Stack.Screen
name="settings/index"
options={{
headerTransparent: Platform.OS === "ios",
title: "Settings",
}}
/>
<Stack.Screen
name="settings/licenses"
options={{
headerTransparent: Platform.OS === "ios",
title: "Open-source licenses",
}}
/>
<Stack.Screen
name="settings/license"
options={{
headerTransparent: Platform.OS === "ios",
title: "License",
}}
/>
<Stack.Screen
name="token/add"
options={{
Expand Down
10 changes: 10 additions & 0 deletions src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import NotificationAddSymbol from "@expo/material-symbols/notification_add.xml";
import NotificationsSymbol from "@expo/material-symbols/notifications.xml";
import PlayArrowSymbol from "@expo/material-symbols/play_arrow.xml";
import RestartAltSymbol from "@expo/material-symbols/restart_alt.xml";
import SettingsSymbol from "@expo/material-symbols/settings.xml";
import SyncSymbol from "@expo/material-symbols/sync.xml";
import { Button, Text as ExpoText, Host, Icon, Row } from "@expo/ui";
import {
Expand Down Expand Up @@ -318,6 +319,15 @@ export default function Tokens() {

<Stack.Header style={stackHeaderStyle} />
<Stack.Toolbar placement="right">
<Stack.Toolbar.Button
icon={Icon.select({
ios: "gearshape",
android: SettingsSymbol,
})}
onPress={() => {
router.navigate("/settings");
}}
/>
{__DEV__ && (
<Stack.Toolbar.Menu
icon={Icon.select({
Expand Down
Loading