diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..1477557
--- /dev/null
+++ b/.env.example
@@ -0,0 +1 @@
+EXPO_PUBLIC_HOME_PAGE_URL=https://afnprojects.com
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 99491cc..9408328 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,7 @@ yarn-error.*
# local env files
.env*.local
+.env
# typescript
*.tsbuildinfo
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 310b919..ae7681a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -6,6 +6,7 @@
},
"typescript.tsdk": "node_modules/typescript/lib",
"cSpell.words": [
+ "persistor",
"scriptian"
]
}
diff --git a/__tests__/Index-test.tsx b/__tests__/Index-test.tsx
index 7447119..c402fe3 100644
--- a/__tests__/Index-test.tsx
+++ b/__tests__/Index-test.tsx
@@ -1,6 +1,6 @@
import { render } from "@testing-library/react-native";
-import Index from "@/app/index";
+import Index from "@/src/app/index";
describe("", () => {
test("Text renders correctly on Index", () => {
diff --git a/app/_layout.tsx b/app/_layout.tsx
deleted file mode 100644
index d2a8b0b..0000000
--- a/app/_layout.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { Stack } from "expo-router";
-
-export default function RootLayout() {
- return ;
-}
diff --git a/package-lock.json b/package-lock.json
index a59c388..4100082 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,9 +9,11 @@
"version": "0.0.1",
"dependencies": {
"@expo/vector-icons": "^15.0.2",
+ "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
+ "@reduxjs/toolkit": "^2.9.0",
"expo": "~54.0.13",
"expo-constants": "~18.0.9",
"expo-dev-client": "~6.0.15",
@@ -34,7 +36,9 @@
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-webview": "13.15.0",
- "react-native-worklets": "0.5.1"
+ "react-native-worklets": "0.5.1",
+ "react-redux": "^9.2.0",
+ "redux-persist": "^6.0.0"
},
"devDependencies": {
"@testing-library/react-native": "^13.3.3",
@@ -3199,6 +3203,18 @@
}
}
},
+ "node_modules/@react-native-async-storage/async-storage": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
+ "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
+ "license": "MIT",
+ "dependencies": {
+ "merge-options": "^3.0.4"
+ },
+ "peerDependencies": {
+ "react-native": "^0.0.0-0 || >=0.65 <1.0"
+ }
+ },
"node_modules/@react-native/assets-registry": {
"version": "0.81.4",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.4.tgz",
@@ -3543,6 +3559,32 @@
"nanoid": "^3.3.11"
}
},
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
+ "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^10.0.3",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -3574,6 +3616,18 @@
"@sinonjs/commons": "^3.0.0"
}
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
+ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@testing-library/react-native": {
"version": "13.3.3",
"resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz",
@@ -3900,6 +3954,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
"node_modules/@types/yargs": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@@ -9241,6 +9301,16 @@
"node": ">=16.x"
}
},
+ "node_modules/immer": {
+ "version": "10.1.3",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
+ "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -9680,6 +9750,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -11627,6 +11706,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
+ "node_modules/merge-options": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
+ "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-obj": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -13479,6 +13570,29 @@
"async-limiter": "~1.0.0"
}
},
+ "node_modules/react-redux": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -13605,6 +13719,30 @@
"node": ">=8"
}
},
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/redux-persist": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz",
+ "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": ">4.0.0"
+ }
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -13755,6 +13893,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/reselect": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
+ "license": "MIT"
+ },
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
diff --git a/package.json b/package.json
index 5447521..3fb4daf 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,11 @@
},
"dependencies": {
"@expo/vector-icons": "^15.0.2",
+ "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
+ "@reduxjs/toolkit": "^2.9.0",
"expo": "~54.0.13",
"expo-constants": "~18.0.9",
"expo-dev-client": "~6.0.15",
@@ -38,7 +40,9 @@
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-webview": "13.15.0",
- "react-native-worklets": "0.5.1"
+ "react-native-worklets": "0.5.1",
+ "react-redux": "^9.2.0",
+ "redux-persist": "^6.0.0"
},
"devDependencies": {
"@testing-library/react-native": "^13.3.3",
@@ -55,4 +59,4 @@
"jest": {
"preset": "jest-expo"
}
-}
\ No newline at end of file
+}
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
new file mode 100644
index 0000000..58a5ff5
--- /dev/null
+++ b/src/app/_layout.tsx
@@ -0,0 +1,33 @@
+import { Stack } from "expo-router";
+import { StatusBar } from "expo-status-bar";
+import React from "react";
+import { ActivityIndicator, Text, View } from "react-native";
+import { SafeAreaProvider } from "react-native-safe-area-context";
+import { Provider } from "react-redux";
+import { PersistGate } from "redux-persist/integration/react";
+
+import { persistor, store } from "../store";
+
+const LoadingComponent = () => (
+
+
+
+ Loading Scriptian...
+
+
+);
+
+export default function RootLayout() {
+ return (
+
+ } persistor={persistor}>
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/index.tsx b/src/app/index.tsx
similarity index 100%
rename from app/index.tsx
rename to src/app/index.tsx
diff --git a/src/features/browser/browserSlice.ts b/src/features/browser/browserSlice.ts
new file mode 100644
index 0000000..76df1c5
--- /dev/null
+++ b/src/features/browser/browserSlice.ts
@@ -0,0 +1,118 @@
+import { createSlice, nanoid, PayloadAction } from "@reduxjs/toolkit";
+import { BrowserState, BrowserTab } from "./types";
+
+const homePageUrl =
+ process.env.EXPO_PUBLIC_HOME_PAGE_URL || "https://afnprojects.com";
+
+const initialState: BrowserState = {
+ tabs: [
+ {
+ id: "1",
+ url: homePageUrl,
+ title: "Ali Fuat Numanoglu's Projects",
+ isLoading: false,
+ canGoBack: false,
+ canGoForward: false,
+ activeScripts: [],
+ },
+ ],
+ activeTabId: "1",
+ showTabs: false,
+ bookmarks: [],
+};
+
+const browserSlice = createSlice({
+ name: "browser",
+ initialState,
+ reducers: {
+ addTab: (state, action: PayloadAction<{ url?: string }>) => {
+ const newTab: BrowserTab = {
+ id: nanoid(),
+ url: action.payload.url || homePageUrl,
+ title: "New Tab",
+ isLoading: false,
+ canGoBack: false,
+ canGoForward: false,
+ activeScripts: [],
+ };
+ state.tabs.push(newTab);
+ state.activeTabId = newTab.id;
+ state.showTabs = false;
+ },
+
+ closeTab: (state, action: PayloadAction) => {
+ const tabId = action.payload;
+ state.tabs = state.tabs.filter((tab) => tab.id !== tabId);
+
+ if (state.activeTabId === tabId && state.tabs.length > 0) {
+ state.activeTabId = state.tabs[0].id;
+ }
+
+ if (state.tabs.length <= 1) {
+ state.showTabs = false;
+ }
+ },
+
+ updateTab: (
+ state,
+ action: PayloadAction<{
+ tabId: string;
+ updates: Partial;
+ }>
+ ) => {
+ const { tabId, updates } = action.payload;
+ const tab = state.tabs.find((t) => t.id === tabId);
+ if (tab) {
+ Object.assign(tab, updates);
+ }
+ },
+
+ switchTab: (state, action: PayloadAction) => {
+ state.activeTabId = action.payload;
+ state.showTabs = false;
+ },
+
+ toggleTabView: (state) => {
+ state.showTabs = !state.showTabs;
+ },
+
+ navigateToUrl: (
+ state,
+ action: PayloadAction<{ tabId: string; url: string }>
+ ) => {
+ const { tabId, url } = action.payload;
+ const tab = state.tabs.find((t) => t.id === tabId);
+ if (tab) {
+ tab.url = url;
+ tab.isLoading = true;
+ tab.canGoBack = false;
+ tab.canGoForward = false;
+ }
+ },
+
+ addBookmark: (state, action: PayloadAction) => {
+ if (!state.bookmarks.includes(action.payload)) {
+ state.bookmarks.push(action.payload);
+ }
+ },
+
+ removeBookmark: (state, action: PayloadAction) => {
+ state.bookmarks = state.bookmarks.filter(
+ (bookmark) => bookmark !== action.payload
+ );
+ },
+ },
+});
+
+export const {
+ addTab,
+ closeTab,
+ updateTab,
+ switchTab,
+ toggleTabView,
+ navigateToUrl,
+ addBookmark,
+ removeBookmark,
+} = browserSlice.actions;
+
+export default browserSlice.reducer;
diff --git a/src/features/browser/types.ts b/src/features/browser/types.ts
new file mode 100644
index 0000000..74bac0e
--- /dev/null
+++ b/src/features/browser/types.ts
@@ -0,0 +1,17 @@
+export interface BrowserTab {
+ id: string;
+ url: string;
+ title: string;
+ favicon?: string;
+ isLoading: boolean;
+ canGoBack: boolean;
+ canGoForward: boolean;
+ activeScripts: string[];
+}
+
+export interface BrowserState {
+ tabs: BrowserTab[];
+ activeTabId: string;
+ showTabs: boolean;
+ bookmarks: string[];
+}
diff --git a/src/features/scripts/api/scriptApi.ts b/src/features/scripts/api/scriptApi.ts
new file mode 100644
index 0000000..7f06d0d
--- /dev/null
+++ b/src/features/scripts/api/scriptApi.ts
@@ -0,0 +1,28 @@
+/**
+ * Fetches and returns the contents of a script from a given URL.
+ * @param url - The URL of the script to import.
+ * @returns The script code as a string.
+ * @throws If the fetch fails or the URL is invalid.
+ */
+export async function importScript(url: string): Promise {
+ if (!url || typeof url !== "string") {
+ throw new TypeError("A valid URL string must be provided.");
+ }
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
+
+ try {
+ const response = await fetch(url, { signal: controller.signal });
+ if (!response.ok) {
+ throw new Error(
+ `Failed to import script: ${response.status} ${response.statusText}`
+ );
+ }
+ return await response.text();
+ } catch (error) {
+ throw new Error(`Error importing script: ${(error as Error).message}`);
+ } finally {
+ clearTimeout(timeout);
+ }
+}
diff --git a/src/features/scripts/scriptsSlice.ts b/src/features/scripts/scriptsSlice.ts
new file mode 100644
index 0000000..6b1cacd
--- /dev/null
+++ b/src/features/scripts/scriptsSlice.ts
@@ -0,0 +1,165 @@
+import {
+ createAsyncThunk,
+ createSlice,
+ nanoid,
+ PayloadAction,
+} from "@reduxjs/toolkit";
+import { importScript } from "./api/scriptApi";
+import { ScriptExecution, ScriptsState, UserScript } from "./types";
+import { validateScript } from "./utils";
+
+// Async thunk to add a new user script by importing from a URL or direct input
+export const addUserScript = createAsyncThunk<
+ UserScript,
+ {
+ url?: string;
+ code?: string;
+ name: string;
+ description: string;
+ urlPatterns: string[];
+ runAt: "document-start" | "document-ready" | "document-end";
+ },
+ { rejectValue: string }
+>("scripts/createUserScript", async (args, { rejectWithValue }) => {
+ try {
+ let code: string | undefined;
+
+ if (args.code) {
+ code = args.code;
+ } else if (args.url) {
+ code = await importScript(args.url);
+ } else {
+ return rejectWithValue("Either 'url' or 'code' must be provided.");
+ }
+
+ // Validate the script code before creating the script
+ const validation = validateScript(code);
+ if (!validation.valid) {
+ return rejectWithValue(validation.error ?? "Unknown validation error");
+ }
+
+ const now = new Date().toISOString();
+ return {
+ id: nanoid(),
+ name: args.name,
+ description: args.description,
+ runAt: args.runAt,
+ code,
+ urlPatterns: args.urlPatterns,
+ enabled: true,
+ createdAt: now,
+ updatedAt: now,
+ };
+ } catch (error) {
+ return rejectWithValue((error as Error).message);
+ }
+});
+
+const initialState: ScriptsState = {
+ userScripts: {},
+ executions: {},
+ isLoading: false,
+ error: null,
+};
+
+const scriptsSlice = createSlice({
+ name: "scripts",
+ initialState,
+ reducers: {
+ updateUserScript: {
+ reducer: (
+ state,
+ action: PayloadAction<{
+ id: string;
+ updates: Partial;
+ updatedAt: string;
+ }>
+ ) => {
+ const { id, updates, updatedAt } = action.payload;
+ const script = state.userScripts[id];
+ if (script) {
+ Object.assign(script, { ...updates, updatedAt });
+ }
+ },
+ prepare: (id: string, updates: Partial) => {
+ return {
+ payload: {
+ id,
+ updates,
+ updatedAt: new Date().toISOString(),
+ },
+ };
+ },
+ },
+
+ deleteUserScript: (state, action: PayloadAction) => {
+ const scriptId = action.payload;
+ // Remove the script
+ delete state.userScripts[scriptId];
+ // Also remove related execution logs
+ Object.entries(state.executions).forEach(([id, execution]) => {
+ if (execution.scriptId === scriptId) {
+ delete state.executions[id];
+ }
+ });
+ },
+
+ toggleUserScript: {
+ reducer: (
+ state,
+ action: PayloadAction<{ id: string; updatedAt: string }>
+ ) => {
+ const script = state.userScripts[action.payload.id];
+ if (script) {
+ script.enabled = !script.enabled;
+ script.updatedAt = action.payload.updatedAt;
+ }
+ },
+ prepare: (id: string) => ({
+ payload: {
+ id,
+ updatedAt: new Date().toISOString(),
+ },
+ }),
+ },
+
+ logScriptExecution: (state, action: PayloadAction) => {
+ state.executions[action.payload.id] = action.payload;
+ },
+
+ clearExecutionLogs: (state) => {
+ state.executions = {};
+ },
+
+ setError: (state, action: PayloadAction) => {
+ state.error = action.payload;
+ },
+ },
+
+ extraReducers: (builder) => {
+ builder
+ .addCase(addUserScript.pending, (state) => {
+ state.isLoading = true;
+ state.error = null;
+ })
+ .addCase(addUserScript.fulfilled, (state, action) => {
+ state.isLoading = false;
+ state.userScripts[action.payload.id] = action.payload;
+ })
+ .addCase(addUserScript.rejected, (state, action) => {
+ state.isLoading = false;
+ state.error = action.error.message || "Script import failed!";
+ });
+ },
+});
+
+export const {
+ updateUserScript,
+ deleteUserScript,
+ toggleUserScript,
+ logScriptExecution,
+ clearExecutionLogs,
+ setError,
+} = scriptsSlice.actions;
+
+export default scriptsSlice.reducer;
diff --git a/src/features/scripts/types.ts b/src/features/scripts/types.ts
new file mode 100644
index 0000000..b8e5c50
--- /dev/null
+++ b/src/features/scripts/types.ts
@@ -0,0 +1,28 @@
+export interface UserScript {
+ id: string;
+ name: string;
+ description?: string;
+ code: string;
+ urlPatterns: string[];
+ enabled: boolean;
+ runAt: "document-start" | "document-ready" | "document-end";
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ScriptExecution {
+ id: string;
+ scriptId: string;
+ url: string;
+ timestamp: string;
+ success: boolean;
+ error?: string;
+ executionTime?: number;
+}
+
+export interface ScriptsState {
+ userScripts: Record;
+ executions: Record;
+ isLoading: boolean;
+ error: string | null;
+}
diff --git a/src/features/scripts/utils/index.ts b/src/features/scripts/utils/index.ts
new file mode 100644
index 0000000..38107e4
--- /dev/null
+++ b/src/features/scripts/utils/index.ts
@@ -0,0 +1,21 @@
+export interface ScriptValidationResult {
+ valid: boolean;
+ error: string | null;
+}
+
+/**
+ * Validates the syntax of a given script code string.
+ *
+ * @param code - The script code to validate.
+ * @returns An object containing the validation result:
+ * - `valid`: `true` if the code is syntactically correct, `false` otherwise.
+ * - `error`: The error message if validation fails, or `null` if validation succeeds.
+ */
+export function validateScript(code: string): ScriptValidationResult {
+ try {
+ new Function(code); // Basic syntax check
+ return { valid: true, error: null };
+ } catch (error) {
+ return { valid: false, error: (error as Error).message };
+ }
+}
diff --git a/src/features/settings/settingsSlice.ts b/src/features/settings/settingsSlice.ts
new file mode 100644
index 0000000..c46cafa
--- /dev/null
+++ b/src/features/settings/settingsSlice.ts
@@ -0,0 +1,56 @@
+import { createSlice, PayloadAction } from "@reduxjs/toolkit";
+import { SettingsState } from "./types";
+
+const initialState: SettingsState = {
+ scriptsEnabled: true,
+ maxExecutionTime: 5000,
+ logExecutions: true,
+ theme: "light",
+ autoUpdateScripts: false,
+};
+
+const settingsSlice = createSlice({
+ name: "settings",
+ initialState,
+ reducers: {
+ updateSettings: (state, action: PayloadAction>) => {
+ Object.assign(state, action.payload);
+ },
+
+ toggleScriptsEnabled: (state) => {
+ state.scriptsEnabled = !state.scriptsEnabled;
+ },
+
+ setMaxExecutionTime: (state, action: PayloadAction) => {
+ state.maxExecutionTime = Math.max(1000, Math.min(30000, action.payload));
+ },
+
+ toggleLogExecutions: (state) => {
+ state.logExecutions = !state.logExecutions;
+ },
+
+ setTheme: (state, action: PayloadAction<"light" | "dark">) => {
+ state.theme = action.payload;
+ },
+
+ toggleAutoUpdateScripts: (state) => {
+ state.autoUpdateScripts = !state.autoUpdateScripts;
+ },
+
+ resetSettings: (state) => {
+ Object.assign(state, initialState);
+ },
+ },
+});
+
+export const {
+ updateSettings,
+ toggleScriptsEnabled,
+ setMaxExecutionTime,
+ toggleLogExecutions,
+ setTheme,
+ toggleAutoUpdateScripts,
+ resetSettings,
+} = settingsSlice.actions;
+
+export default settingsSlice.reducer;
diff --git a/src/features/settings/types.ts b/src/features/settings/types.ts
new file mode 100644
index 0000000..fe08929
--- /dev/null
+++ b/src/features/settings/types.ts
@@ -0,0 +1,7 @@
+export interface SettingsState {
+ scriptsEnabled: boolean;
+ maxExecutionTime: number;
+ logExecutions: boolean;
+ theme: "light" | "dark";
+ autoUpdateScripts: boolean;
+}
diff --git a/src/hooks/useAppDispatch.ts b/src/hooks/useAppDispatch.ts
new file mode 100644
index 0000000..48c0f38
--- /dev/null
+++ b/src/hooks/useAppDispatch.ts
@@ -0,0 +1,4 @@
+import { useDispatch } from "react-redux";
+import type { AppDispatch } from "../store";
+
+export const useAppDispatch = () => useDispatch();
diff --git a/src/hooks/useAppSelector.ts b/src/hooks/useAppSelector.ts
new file mode 100644
index 0000000..72f875a
--- /dev/null
+++ b/src/hooks/useAppSelector.ts
@@ -0,0 +1,4 @@
+import { TypedUseSelectorHook, useSelector } from "react-redux";
+import type { RootState } from "../store";
+
+export const useAppSelector: TypedUseSelectorHook = useSelector;
diff --git a/src/store/index.ts b/src/store/index.ts
new file mode 100644
index 0000000..de57f88
--- /dev/null
+++ b/src/store/index.ts
@@ -0,0 +1,46 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { combineReducers, configureStore } from "@reduxjs/toolkit";
+import {
+ FLUSH,
+ PAUSE,
+ PERSIST,
+ persistReducer,
+ persistStore,
+ PURGE,
+ REGISTER,
+ REHYDRATE,
+} from "redux-persist";
+
+import browserReducer from "../features/browser/browserSlice";
+import scriptsReducer from "../features/scripts/scriptsSlice";
+import settingsReducer from "../features/settings/settingsSlice";
+
+const persistConfig = {
+ key: "root",
+ storage: AsyncStorage,
+ whitelist: ["scripts", "settings"],
+};
+
+const rootReducer = combineReducers({
+ browser: browserReducer,
+ scripts: scriptsReducer,
+ settings: settingsReducer,
+});
+
+const persistedReducer = persistReducer(persistConfig, rootReducer);
+
+export const store = configureStore({
+ reducer: persistedReducer,
+ middleware: (getDefaultMiddleware) =>
+ getDefaultMiddleware({
+ serializableCheck: {
+ ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
+ },
+ }),
+ devTools: __DEV__, // Enable Redux DevTools only in development
+});
+
+export const persistor = persistStore(store);
+
+export type RootState = ReturnType;
+export type AppDispatch = typeof store.dispatch;
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..2f47367
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,9 @@
+import { BrowserState } from "../features/browser/types";
+import { ScriptsState } from "../features/scripts/types";
+import { SettingsState } from "../features/settings/types";
+
+export interface RootState {
+ browser: BrowserState;
+ scripts: ScriptsState;
+ settings: SettingsState;
+}