initial settings implementation

add fs/open-directory-selector method
removed dangerous core/lib/locations.ts
This commit is contained in:
DH
2025-09-01 13:34:54 +03:00
parent 6d322e5465
commit fd607d8a35
14 changed files with 299 additions and 69 deletions

View File

@@ -7,6 +7,9 @@
},
{
"name": "explorer"
},
{
"name": "fs"
}
]
}

View File

@@ -1,10 +1,10 @@
import { app, net, session, BrowserWindow, ipcMain } from 'electron';
import * as locations from '$core/locations.js';
import * as fs from '$fs';
import * as core from '$core';
import { PathLike } from 'fs';
import path from 'path';
import fs from 'fs/promises';
import url from 'url';
import nodePath from 'path';
import * as path from '$core/path';
import { fileURLToPath } from 'url';
import { Future } from '$core/Future.js';
import * as explorer from '$explorer';
import { Window } from '$core/Window';
@@ -29,7 +29,7 @@ async function activateMainWindow() {
y: 0,
fullscreen: false,
webPreferences: {
preload: path.join(locations.builtinResourcesPath, "preload.js"),
preload: fileURLToPath(path.join(await fs.fsGetBuiltinResourcesLocation(undefined), "build", "preload.js")),
webSecurity: false
}
});
@@ -45,10 +45,10 @@ async function activateMainWindow() {
}
export async function initialize() {
ipcMain.on('window/create', (_event, options) => {
ipcMain.on('window/create', async (_event, options) => {
const win = new BrowserWindow({
webPreferences: {
preload: path.join(locations.builtinResourcesPath, "preload.mjs"),
preload: fileURLToPath(path.join(await fs.fsGetBuiltinResourcesLocation(undefined), "build", "preload.js")),
},
...options,
});
@@ -100,7 +100,7 @@ export async function initialize() {
await app.whenReady();
const uiDirectory = path.join(locations.builtinResourcesPath, "ui");
const uiUri = path.join(await fs.fsGetBuiltinResourcesLocation(undefined), "build", "ui");
const fixPath = async (loc: PathLike) => {
loc = loc.toString();
@@ -109,22 +109,22 @@ export async function initialize() {
}
try {
const stat = await fs.stat(loc);
if (stat.isFile()) {
const stat = await fs.fsStat(loc);
if (stat.type == FsDirEntryType.File) {
return loc;
}
if (stat.isDirectory()) {
if (stat.type == FsDirEntryType.Directory) {
return fixPath(path.join(loc, "index.html"));
}
} catch {
const ext = path.extname(loc);
const ext = nodePath.extname(loc);
if (ext === ".html") {
return undefined;
}
try {
if ((await fs.stat(loc + ".html")).isFile()) {
if ((await fs.fsStat(loc + ".html")).type == FsDirEntryType.File) {
return loc + ".html";
}
} catch { }
@@ -140,11 +140,11 @@ export async function initialize() {
pathname = "/";
}
const filePath = path.join(uiDirectory, decodeURIComponent(pathname));
const filePath = path.join(uiUri, decodeURIComponent(pathname));
console.log(`open ${filePath}, request ${pathname}`);
const relativePath = path.relative(uiDirectory, filePath);
const isSafe = !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
const relativePath = nodePath.relative(fileURLToPath(uiUri), filePath);
const isSafe = !relativePath.startsWith('..') && !nodePath.isAbsolute(relativePath);
if (!isSafe) {
return new Response('bad request', {
@@ -154,10 +154,10 @@ export async function initialize() {
}
try {
const absolutePath = await fixPath(path.join(uiDirectory, relativePath));
const absolutePath = await fixPath(path.join(uiUri, relativePath));
if (absolutePath) {
return net.fetch(url.pathToFileURL(absolutePath).toString());
return net.fetch(absolutePath);
}
} catch { }

View File

@@ -1,9 +0,0 @@
import path from "path";
export const builtinResourcesPath = import.meta.dirname;
export const rootPath = path.dirname(process.execPath);
export const configPath = rootPath;
export const extensionsPath = path.join(rootPath, "extensions");
export const localExtensionsPath = "resourcesPath" in process && typeof process.resourcesPath == "string" ?
path.join(process.resourcesPath, "extensions") :
path.resolve(builtinResourcesPath, "..", "extensions");

View File

@@ -2,6 +2,7 @@
import * as Disposable from '$core/Disposable';
import { createError } from '$core/Error';
import * as bridge from '../lib/bridge';
import { ErrorCode } from '$/enums';
export { viewPush, viewSet, viewPop } from '../lib/bridge';
export function setOnCall(_cb: (...args: any[]) => any) {}

View File

@@ -1,2 +0,0 @@
export { pickDirectory } from '@react-native-documents/picker';

View File

@@ -1,5 +0,0 @@
import type { pickDirectory as impl } from '@react-native-documents/picker';
export function pickDirectory(... _params: Parameters<typeof impl>): ReturnType<typeof impl> {
throw new Error('not implemented for web');
}

View File

@@ -1,6 +1,6 @@
import * as path from '$/path';
import * as fs from '$fs';
import { findComponent, findComponentById, getComponentId, unregisterComponent } from './ComponentInstance';
import { findComponent, findComponentById, unregisterComponent } from './ComponentInstance';
import { createError } from 'lib/Error';
import { getLauncher } from './Launcher';
import { Extension } from './Extension';

View File

@@ -4,7 +4,6 @@ import { Target } from "./Target";
import { fork, spawn } from "child_process";
import { Duplex } from "stream";
import { EventEmitter } from "events";
import * as locations from '$/locations';
import { fileURLToPath } from "url";
const nativeLauncher: Launcher = {
@@ -12,7 +11,7 @@ const nativeLauncher: Launcher = {
path = fileURLToPath(path);
const newProcess = spawn(path, args, {
argv0: path,
cwd: locations.rootPath,
cwd: dirname(process.execPath),
signal: params.signal,
stdio: 'pipe'
});

View File

@@ -186,6 +186,10 @@
"get-config-location": {
"handler": "handleGetConfigLocation",
"returns": "string"
},
"open-directory-selector": {
"handler": "handleOpenDirectorySelector",
"returns": "string"
}
}
}

View File

@@ -1,4 +1,6 @@
import { createError } from "$core/Error";
import { pickDirectory } from '@react-native-documents/picker';
export async function initialize() {
throw createError(ErrorCode.InternalError, "not implemented");
@@ -35,3 +37,13 @@ export function getBuiltinResourcesLocation(_caller: Component, _request: FsGetB
export function getConfigLocation(_caller: Component, _request: FsGetConfigLocationRequest): Promise<FsGetConfigLocationResponse> {
throw createError(ErrorCode.InternalError, "not implemented");
}
export async function openDirectorySelector(caller: Component, request: FsOpenDirectorySelectorRequest): Promise<FsOpenDirectorySelectorResponse> {
try {
return (await pickDirectory({
requestLongTermAccess: true
})).uri;
} catch {
throw createError(ErrorCode.RequestCancelled);
}
}

View File

@@ -5,6 +5,8 @@ import nodeFs from 'fs/promises';
import { app } from 'electron';
import nodePath from 'path';
import * as path from '$core/path';
import { dialog } from 'electron';
import { pathToFileURL } from 'url';
class NativeFile implements FileInterface {
constructor(private id: number, private handle: nodeFs.FileHandle) { }
@@ -198,3 +200,18 @@ export function getBuiltinResourcesLocation(_caller: Component, _request: FsGetB
export function getConfigLocation(_caller: Component, _request: FsGetConfigLocationRequest): FsGetConfigLocationResponse {
return path.toURI(nodePath.dirname(process.execPath));
}
export async function openDirectorySelector(caller: Component, request: FsOpenDirectorySelectorRequest): Promise<FsOpenDirectorySelectorResponse> {
const result = await dialog.showOpenDialog({
properties: [
'openDirectory',
'createDirectory',
]
});
if (result.canceled || result.filePaths.length != 1) {
throw createError(ErrorCode.RequestCancelled);
}
return pathToFileURL(result.filePaths[0]).toString();
}

View File

@@ -30,6 +30,10 @@ export function handleGetBuiltinResourcesLocation(caller: Component, request: Fs
return fs.getBuiltinResourcesLocation(caller, request);
}
export function handleGetConfigLocation(caller: Component, request: FsGetConfigLocationRequest): Promise<FsGetConfigLocationResponse> {
export function handleGetConfigLocation(caller: Component, request: FsGetConfigLocationRequest) {
return fs.getConfigLocation(caller, request);
}
export function handleOpenDirectorySelector(caller: Component, request: FsOpenDirectorySelectorRequest) {
return fs.openDirectorySelector(caller, request);
}

View File

@@ -1,5 +1,9 @@
{
"name": "settings",
"version": "0.1.0",
"contributions": {}
"dependencies": [
{
"name": "fs"
}
]
}

View File

@@ -6,8 +6,11 @@ import { ThemedText } from '$core/ThemedText';
import { LeftRightViewSelector, UpDownViewSelector } from '$core/ViewSelector';
import { HapticPressable } from '$core/HapticPressable';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { pickDirectory } from '$core/pickDirectory';
import * as fs from '$fs';
import * as core from '$core';
import { Schema, SchemaObject } from '$core/Schema';
import { createError } from '$core/Error';
import { ErrorCode } from '$core/enums';
import Animated, {
Easing,
@@ -129,6 +132,7 @@ type SettingItemCategoryProps = SettingItemBaseProps & SettingItemWithChevron &
type SettingItemListProps = SettingItemBaseProps & {
type: SettingItemType.List;
mode?: SettingTextMode;
items: string[];
onAddItem: (item: string) => void;
onRemoveItem: (index: number) => void;
@@ -185,8 +189,8 @@ const TextInputModal = memo(function ({ visible, onClose, value, onSave, title,
const handlePickFile = async () => {
try {
const result = await pickDirectory({ requestLongTermAccess: true });
setInputValue(result.uri);
const result = await fs.fsOpenDirectorySelector(undefined);
setInputValue(result);
} catch { }
};
@@ -382,19 +386,21 @@ const DateTimeModal = memo(function ({ visible, onClose, value, onSave, title, m
);
});
const ListManagerModal = memo(function ({ visible, onClose, items, onSave, title, placeholder }: {
const ListManagerModal = memo(function ({ visible, onClose, items, onSave, title, placeholder, mode = SettingTextMode.Plain }: {
visible: boolean;
onClose: () => void;
items: string[];
onSave: (items: string[]) => void;
title: string;
placeholder?: string;
mode?: SettingTextMode;
}) {
const [listItems, setListItems] = useState([...items]);
const [newItem, setNewItem] = useState('');
const surfaceColor = useThemeColor("surface");
const outlineColor = useThemeColor("outline");
const surfaceContainerColor = useThemeColor("surfaceContainer");
const primaryContainerColor = useThemeColor("primaryContainer");
const primaryColor = useThemeColor("primary");
const onPrimaryColor = useThemeColor("onPrimary");
const onSurfaceColor = useThemeColor("onSurface");
@@ -413,6 +419,14 @@ const ListManagerModal = memo(function ({ visible, onClose, items, onSave, title
}
};
const handlePickFile = async () => {
try {
const result = await fs.fsOpenDirectorySelector(undefined);
setListItems([...listItems, result]);
setNewItem('');
} catch { }
};
const removeItem = (index: number) => {
setListItems(listItems.filter((_, i) => i !== index));
};
@@ -429,19 +443,29 @@ const ListManagerModal = memo(function ({ visible, onClose, items, onSave, title
</View>
{/* Add new item input */}
<View style={styles.addItemContainer}>
<TextInput
style={[styles.textInput, { backgroundColor: surfaceContainerColor, borderColor: outlineColor, color: onSurfaceColor, flex: 1 }]}
value={newItem}
onChangeText={setNewItem}
placeholder={placeholder || "Add new item"}
placeholderTextColor={onSurfaceVariantColor}
onSubmitEditing={addItem}
/>
<HapticPressable onPress={addItem} style={[styles.addButton, { backgroundColor: primaryColor }]}>
<ThemedIcon iconSet="Ionicons" name="add" size={20} color={{ light: 'white', dark: 'white' }} />
</HapticPressable>
</View>
{(mode == SettingTextMode.Plain || mode == SettingTextMode.Url) &&
<View style={styles.addItemContainer}>
<TextInput
style={[styles.textInput, { backgroundColor: surfaceContainerColor, borderColor: outlineColor, color: onSurfaceColor, flex: 1 }]}
value={newItem}
onChangeText={setNewItem}
placeholder={placeholder || "Add new item"}
placeholderTextColor={onSurfaceVariantColor}
onSubmitEditing={addItem}
/>
<HapticPressable onPress={addItem} style={[styles.addButton, { backgroundColor: primaryColor }]}>
<ThemedIcon iconSet="Ionicons" name="add" size={20} color={{ light: 'white', dark: 'white' }} />
</HapticPressable>
</View>
}
{mode == SettingTextMode.Path &&
<View style={styles.addItemContainer}>
<HapticPressable onPress={handlePickFile} style={[styles.filePickerButton, { backgroundColor: primaryContainerColor }]}>
<ThemedIcon iconSet="Ionicons" name="folder-outline" size={20} />
<ThemedText style={styles.filePickerText}>Browse Files</ThemedText>
</HapticPressable>
</View>
}
{/* List items */}
<ScrollView style={styles.listContainer} showsVerticalScrollIndicator={false}>
@@ -733,6 +757,7 @@ const SettingsItem = memo(function (props: SettingsItemProps & { isLast: boolean
visible={modalVisible}
onClose={() => setModalVisible(false)}
items={props.items}
mode={props.mode}
onSave={(newItems) => {
// Update items by calling onAddItem/onRemoveItem appropriately
// For simplicity, we'll replace the entire list
@@ -1218,7 +1243,6 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
marginBottom: 16,
minHeight: 44,
},
filePickerButton: {
@@ -1338,16 +1362,175 @@ const styles = StyleSheet.create({
},
});
function makeSettingsItemProp(value: any, schema: Schema, name: string, path: string) {
switch (schema.type) {
case 'boolean': {
const result: SettingsItemProps = {
type: SettingItemType.Toggle,
title: schema.label || name,
value,
onToggle: (value) => {
core.settingsSet({
path,
value
})
}
};
return result;
}
case 'string': {
const result: SettingsItemProps = {
type: SettingItemType.Text,
title: schema.label || name,
value,
onTextChange: (value) => {
core.settingsSet({
path,
value
})
}
};
return result;
}
case 'number': {
const result: SettingsItemProps = {
type: SettingItemType.Number,
title: schema.label || name,
value,
onNumberChange: (value) => {
core.settingsSet({
path,
value
})
}
};
return result;
}
case 'path': {
const result: SettingsItemProps = {
type: SettingItemType.Text,
mode: SettingTextMode.Path,
title: schema.label || name,
value,
onTextChange: (value) => {
core.settingsSet({
path,
value
})
}
};
return result;
}
case 'variant': {
const result: SettingsItemProps = {
type: SettingItemType.SingleSelection,
title: schema.label || name,
options: schema.choices,
selectedIndex: schema.choices.findIndex(value),
onSelectionChange: (selection) => {
core.settingsSet({
path,
value: schema.choices[selection]
});
}
};
return result;
}
case 'array': {
const result: SettingsItemProps = {
type: SettingItemType.List,
title: schema.label || name,
items: value,
mode: schema.items.type == "path" ? SettingTextMode.Path : SettingTextMode.Plain,
onAddItem: (item) => {
value.push(item);
core.settingsSet({
path,
value
});
},
onRemoveItem: (index) => {
value.splice(index, 1);
core.settingsSet({
path,
value
});
},
};
return result;
}
case 'object': {
const result: SettingsItemProps = {
type: SettingItemType.Category,
title: schema.label || name,
};
return result;
}
}
throw createError(ErrorCode.InternalError, `Unimplemented settings type ${(schema as any).type}`);
}
function makeSettingsSections(value: any, schema: SchemaObject, name: string, path: string) {
const result: SettingsSection = {
title: name,
items: Object.keys(schema.properties).map(propertyName => {
return makeSettingsItemProp(value[propertyName], schema.properties[propertyName], propertyName, path + "/" + propertyName)
})
};
return [result];
}
function makeRootSettingsObject(value: object, schema: SchemaObject, path = "") {
const result: SettingsCategory[] = [];
Object.keys(schema.properties).forEach(propertyName => {
const property = schema.properties[propertyName];
const sections = makeSettingsSections(
(value as any)[propertyName], property as SchemaObject,
property.label ?? propertyName, path + "/" + propertyName);
const category: SettingsCategory = {
icon: "settings-outline",
iconSet: "Ionicons",
title: property.label ?? propertyName,
sections
};
result.push(category);
});
return result;
}
export function Settings(_props?: Props) {
const dimension = useWindowDimensions();
const currentShortView = dimension.width <= CATEGORIES_THRESHOLD;
const [shortView, setShortView] = useState(currentShortView);
const [activeTab, setActiveTab] = useState(shortView ? -1 : 0);
const [activeTab, setActiveTab] = useState(shortView ? -1 : -1);
const [lastActiveTab, setLastActiveTab] = useState(0);
const backgroundColor = useThemeColor("background");
const surfaceContainerColor = useThemeColor("surfaceContainer");
const secondaryContainerColor = useThemeColor("surfaceContainerHigh");
const [showContentView, setContentView] = useState(false);
const [categories, setCategories] = useState(settingsCategories);
const [updateIndex, setUpdateIndex] = useState(0);
const insets = useSafeAreaInsets();
@@ -1367,10 +1550,29 @@ export function Settings(_props?: Props) {
setContentView(false);
};
const showContent = (!shortView || showContentView) && activeTab >= 0;
const showCategories = !showContentView || !shortView;
const showContent = (activeTab >= 0 || lastActiveTab >= 0) && (!shortView || showContentView) && activeTab >= 0;
const showCategories = (activeTab >= 0 || lastActiveTab >= 0) && (!showContentView || !shortView);
useEffect(() => {
(async () => {
try {
const { value, schema } = await core.settingsGet({ path: "" });
const categories = makeRootSettingsObject(value as object, schema as SchemaObject);
setCategories(categories);
setUpdateIndex(updateIndex + 1);
} catch (e) {
console.error(`failed to fetch settings`, e);
}
})();
}, []);
useEffect(() => {
if (updateIndex == 0) {
return;
}
if (currentShortView != shortView) {
if (!shortView) {
setContentView(true);
@@ -1426,9 +1628,9 @@ export function Settings(_props?: Props) {
<View style={styles.menuContainer}>
<View style={styles.settingsTabsContainer}>
<ScrollView showsVerticalScrollIndicator={false}>
{settingsCategories.map((category, index) => (
{categories.map((category, index) => (
<SettingsTab
key={category.title}
key={updateIndex + category.title}
title={category.title}
iconName={category.icon}
iconSet={category.iconSet}
@@ -1443,9 +1645,9 @@ export function Settings(_props?: Props) {
);
const contentView = (
<View style={[styles.contentContainer, safeArea.content, { backgroundColor: shortView ? surfaceContainerColor : secondaryContainerColor }]}>
<View key={updateIndex} style={[styles.contentContainer, safeArea.content, { backgroundColor: shortView ? surfaceContainerColor : secondaryContainerColor }]}>
<UpDownViewSelector
list={settingsCategories}
list={categories}
style={[styles.contentSelector, { backgroundColor: secondaryContainerColor }]}
renderItem={(category) => <SettingsContent category={category} />}
selectedItem={activeTab < 0 ? lastActiveTab : activeTab}
@@ -1454,16 +1656,16 @@ export function Settings(_props?: Props) {
)
const shortContentView = (
<View style={[styles.contentContainer, safeArea.content, { backgroundColor: shortView ? surfaceContainerColor : secondaryContainerColor }]}>
<View key={updateIndex} style={[styles.contentContainer, safeArea.content, { backgroundColor: shortView ? surfaceContainerColor : secondaryContainerColor }]}>
<View style={[styles.contentHeaderContainer, { backgroundColor: surfaceContainerColor }]}>
<HapticPressable onPress={onContentBack} style={styles.backButton}>
<ThemedIcon iconSet="Ionicons" name="chevron-back" size={28} />
</HapticPressable>
<ThemedText type="title" style={styles.headerTitle}>{settingsCategories[activeTab < 0 ? lastActiveTab : activeTab].title}</ThemedText>
<ThemedText type="title" style={styles.headerTitle}>{categories[activeTab < 0 ? lastActiveTab : activeTab].title}</ThemedText>
<View style={styles.headerSpacer} />
</View>
<SettingsContent category={settingsCategories[activeTab < 0 ? lastActiveTab : activeTab]} />
<SettingsContent key={updateIndex} category={categories[activeTab < 0 ? lastActiveTab : activeTab]} />
</View>
)
@@ -1475,8 +1677,8 @@ export function Settings(_props?: Props) {
</View>
}
{shortView &&
<LeftRightViewSelector style={[styles.splitViewContainer]}
list={[categoriesView, shortContentView]} renderItem={x => x} selectedItem={showCategories ? 0 : 1}
<LeftRightViewSelector key={updateIndex} style={[styles.splitViewContainer]}
list={[categoriesView, shortContentView]} renderItem={x => x} selectedItem={showCategories ? 0 : 1}
/>
}
</View>