diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index 756b28d..58d8419 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -29,7 +29,7 @@ jobs: sudo apt install libasound2-dev sudo apt install libwebsocketpp-dev authbind sudo apt install libsdbus-c++-dev libsdbus-c++-bin - sudo apt install libavahi-client-dev + sudo apt install libavahi-client-dev libzip-dev git submodule update --init --recursive ./react-config diff --git a/CMakeLists.txt b/CMakeLists.txt index c0ff004..644d7f4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,10 +1,10 @@ cmake_minimum_required(VERSION 3.16.0) project(pipedal - VERSION 1.2.36 + VERSION 1.2.37 DESCRIPTION "PiPedal Guitar Effect Pedal For Raspberry Pi" HOMEPAGE_URL "https://rerdavies.github.io/pipedal" ) -set (DISPLAY_VERSION "v1.2.36beta2") +set (DISPLAY_VERSION "v1.2.37beta4") set (CMAKE_INSTALL_PREFIX "/usr/") diff --git a/README.md b/README.md index 32b159f..724a076 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@
-Download: v1.2.36 Beta3 +Download: v1.2.36 Beta4 Website: [https://rerdavies.github.io/pipedal](https://rerdavies.github.io/pipedal). -> NEW version 1.2.36 Beta3 release, providing support for Raspberry Pi OS Bookworm. See the [release notes](https://rerdavies.github.io/pipedal/ReleaseNotes) for details. +> NEW version 1.2.36 Beta4 release, providing support for Raspberry Pi OS Bookworm. See the [release notes](https://rerdavies.github.io/pipedal/ReleaseNotes) for details. Use your Raspberry Pi as a guitar effects pedal. Configure and control PiPedal with your phone or tablet. PiPedal running on a Raspberry Pi 4 provides stable super-low-latency audio via external USB audio devices, or internal Raspberry Pi audio hats. diff --git a/config/config.json b/config/config.json index 78dc0ec..4541f52 100644 --- a/config/config.json +++ b/config/config.json @@ -14,14 +14,14 @@ "socketServerAddress": "0.0.0.0:80", /* Number of threads to use for servicing websockets */ - "threads" : 10, + "threads" : 5, "logHttpRequests": false, /* { None=0,Error =1,Warning =2,Info = 3, Debug=4} */ "logLevel": 3, /* Maximum filesize to allow when uploading */ - "maxUploadSize": 1048576, + "maxUploadSize": 536870912, /* Provide access point capture redirects on this gateway. -- provides automatic browser launching on Access Point access. (not implemented) diff --git a/docs/BuildPrerequisites.md b/docs/BuildPrerequisites.md index 2f1512b..d8411f5 100644 --- a/docs/BuildPrerequisites.md +++ b/docs/BuildPrerequisites.md @@ -27,7 +27,7 @@ Run the following commands to install dependent libraries required by the PiPeda sudo apt install -y authbind sudo apt install -y libavahi-client-dev sudo apt install -y libnm-dev - sudo apt install -y libsdbus-c++-dev + sudo apt install -y libsdbus-c++-dev libzip-dev ### Installing Sources diff --git a/docs/Installing.md b/docs/Installing.md index 485d6cd..f091220 100644 --- a/docs/Installing.md +++ b/docs/Installing.md @@ -13,17 +13,17 @@ page_icon: img/Install4.jpg Download the most recent Debian (.deb) package for your platform: -- [Raspberry Pi OS bookworm (64-bit) v1.2.36 Beta3](https://github.com/rerdavies/pipedal/releases/download/) +- [Raspberry Pi OS bookworm (64-bit) v1.2.37 Beta4](https://github.com/rerdavies/pipedal/releases/download/) - [Ubuntu/Raspberry Pi OS bullseyeye (64-bit) v1.2.31](https://github.com/rerdavies/pipedal/releases/download/v1.1.31/pipedal_1.1.31_arm64.deb) -Version 1.2.36 has not yet been tested Ubuntu and Raspberry Pi OS bullseye. On these platforms, we recommend that you use version 1.1.31. +Version 1.2.37 has not yet been tested on Ubuntu or Raspberry Pi OS bullseye. On these platforms, we recommend that you use version 1.1.31. Install the package by running ``` sudo apt update cd ~/Downloads - sudo dpkg -i pipedal_1.2.36_arm64.deb + sudo dpkg -i pipedal_1.2.37_arm64.deb ``` Adjust accordingly if you have downloaded v1.1.31. diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index 93f9175..515e543 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -1,4 +1,26 @@ # Release Notes +## Pipedal 1.2.37 Beta4 + +You can now upload GuitarML Proteus models from the GuitarML project into TooB ML. + +- Download protues models from https://guitarml.com/tonelibrary/tonelib-pro.html +- Load the TooB ML plugin. +- click on the Model control. +- click on the Upload button in the lower left corner of the Model selection dialog. +- Upload the .json model file or .zip model collection that you downloaded from The GuitarML Tone Library. + +Features: +- ToobML allows uploading of models. +- TooBML support for large models (e.g. GuitarML Proteus models) +- Support for sub-directories when uploading files. +- Support for uploading .zip file bundles. + +Fixes for the following issues: +- Dialog button colors follow Android UI conventions. +- automatic provisioning of resource files in .LV2 plugins. +- The web server supports uploading of large files (limited by default to 512MB). + + ## Pipedal 1.2.36 Beta3 Fixes for the following issues: diff --git a/docs/download.md b/docs/download.md index d592bff..2867647 100644 --- a/docs/download.md +++ b/docs/download.md @@ -4,17 +4,17 @@ Download the most recent Debian (.deb) package for your platform: -- Raspberry Pi OS Bookworm (64-bit) v1.2.36 Beta3 +- Raspberry Pi OS Bookworm (64-bit) v1.2.37 Beta4 - Ubuntu 21.04 or Raspberry Pi OS bullseyeyeye (64-bit) v1.1.31 -v1.2.36 Beta3 is does not currently support Ubuntu 21.04, or older versions of Raspberry Pi OS. +v1.2.37 Beta4 is does not currently support Ubuntu 21.04, or older versions of Raspberry Pi OS. Install the package by running ``` sudo apt update cd ~/Downloads - sudo apt-get install ./pipedal_1.2.36_arm64.deb + sudo apt-get install ./pipedal_1.2.37_arm64.deb ``` Follow the instructions in [_Configuring PiPedal After Installation_](https://rerdavies.github.io/pipedal/Configuring.html) to complete the installation. diff --git a/docs/index.md b/docs/index.md index d2c5c5c..5903571 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,13 +1,13 @@ -v1.2.36 +v1.2.37   To download PiPedal, click [here](download.md). -> NEW version 1.2.36 Beta3 release. See the [release notes](https://rerdavies.github.io/pipedal/ReleaseNotes) for details. +> NEW version 1.2.37 Beta4 release. See the [release notes](https://rerdavies.github.io/pipedal/ReleaseNotes) for details. Use your Raspberry Pi as a guitar effects pedal. Configure and control PiPedal with your phone or tablet. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobAmp.so.1.1.36 b/lv2/aarch64/ToobAmp.lv2/ToobAmp.so.1.1.36 index bfbd5fb..7bd073c 100644 Binary files a/lv2/aarch64/ToobAmp.lv2/ToobAmp.so.1.1.36 and b/lv2/aarch64/ToobAmp.lv2/ToobAmp.so.1.1.36 differ diff --git a/lv2/aarch64/ToobAmp.lv2/ToobAmpUI.so b/lv2/aarch64/ToobAmp.lv2/ToobAmpUI.so index 95f5df5..febb08e 100644 Binary files a/lv2/aarch64/ToobAmp.lv2/ToobAmpUI.so and b/lv2/aarch64/ToobAmp.lv2/ToobAmpUI.so differ diff --git a/lv2/aarch64/ToobAmp.lv2/ToobML.ttl b/lv2/aarch64/ToobAmp.lv2/ToobML.ttl index 2c6ad9a..c47f31f 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobML.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobML.ttl @@ -26,6 +26,12 @@ @prefix xsd: . @prefix toobml: . + +toobml:modelFile + a lv2:Parameter; + rdfs:label "Model"; + rdfs:range atom:Path. + toobml:filterGroup a param:ControlGroup , pg:InputGroup ; @@ -66,7 +72,25 @@ Generally, TooB ML uses smaller and more efficient neural networks than does Too TooB ML models use significantly less CPU, although TooB Neural Amp Modeller is significantly more flexible, and has a huge number of downloadable models. -The BluesJR model has an additional Gain control the is used by the BluesJR model to simulate the effect of Blues Junior's gain control. +TooB ML models are compatible with models from the GuitarML project (). You can download new models from the GuitarML project +at + + https://guitarml.com/tonelibrary/tonelib-pro.html + +To use GuitarML models, download them locally using your browser, and then upload them to TooB ML using the following steps: + +- Load Toob ML in PiPedal. + +- Click on the "Models" control. + +- Click on the _Upload_ button in the lower left corner of the selection dialog. + +- Select the file that you downloaded localy. + +TooB ML can upload indiviual .json files, or collections of models in .zip files downloaded from the GuitarML site. + +Some models have an additional Gain control that can be used to simulate the effect of a Gain control on the amplifier +being simulated. The gain control will be enabled if the model you have selected has a gain input. For convenience, tone controls are provided for rough shaping of tone before the input signal is fed to the neural net models. The tone section can be completely bypassed by setting all tone controls to exactly 0.5. @@ -85,7 +109,6 @@ on the edge of breakup. It does not work particularly well for models of heavily compressor. Notionally, Sag Feedback models the effect of transformer sag on intermediate transformer stages (which causes them to clip at a lower level than they ordinarily would); and Sag Out models the effect of transformer sag on the final power stage of a guitar amplifier. - The TooB ML Amplifier is based heavily on code ported from from the RTNeural project (https://github.com/jatinchowdhury18/RTNeural), by Jatin Chowdhury, and uses model files from the GuitarML Neural Pi project (https://github.com/GuitarML/NeuralPi). @@ -94,9 +117,14 @@ and uses model files from the GuitarML Neural Pi project (https://github.com/Gui mod:brand "TooB"; mod:label "TooB ML"; lv2:requiredFeature urid:map, work:schedule ; - lv2:optionalFeature lv2:hardRTCapable; + lv2:optionalFeature state::freePath, state:makePath, state:mapPath ; lv2:extensionData state:interface, work:interface; + patch:readable + toobml:modelFile; + patch:writable + toobml:modelFile; + patch:readable ; @@ -128,12 +156,14 @@ and uses model files from the GuitarML Neural Pi project (https://github.com/Gui units:unit units:db ; ], [ + # legagcy for loading of old instances. a lv2:InputPort, lv2:ControlPort ; + lv2:portProperty epp:notOnGUI; lv2:index 2 ; lv2:symbol "model" ; lv2:name "Model" ; - lv2:default 0.0 ; - lv2:minimum 0.0 ; + lv2:default -1.0 ; + lv2:minimum -1.0 ; lv2:maximum 32.0 ; lv2:portProperty lv2:enumeration ; lv2:scalePoint @@ -462,6 +492,31 @@ and uses model files from the GuitarML Neural Pi project (https://github.com/Gui a pipedal_ui:ui ; + pipedal_ui:fileProperties + [ + a pipedal_ui:fileProperty; + rdfs:label "Model" ; + pipedal_ui:directory "ToobMlModels"; + lv2:index 2 ; + pipedal_ui:patchProperty toobml:modelFile ; + pipedal_ui:resourceDirectory "models/tones"; + + pipedal_ui:fileTypes + [ + a pipedal_ui:fileType; + rdfs:label ".json file"; + pipedal_ui:fileExtension ".json"; + pipedal_ui:mimeType "application/json"; + ],[ + a pipedal_ui:fileType; + rdfs:label ".ml file"; + pipedal_ui:fileExtension ".zip"; + pipedal_ui:mimeType "application/zip"; + + ] + ; + ]; + pipedal_ui:frequencyPlot [ lv2:index 8 ; diff --git a/react/public/var/config.json b/react/public/var/config.json index 80d344a..f808ca9 100644 --- a/react/public/var/config.json +++ b/react/public/var/config.json @@ -1,8 +1,8 @@ { - "socket_server_port": 80, + "socket_server_port": 8080, "socket_server_address": "*", "debug": true, - "max_upload_size": 1048576, + "max_upload_size": 536870912, "fakeAndroid": false, "ui_plugins": [] } \ No newline at end of file diff --git a/react/src/App.tsx b/react/src/App.tsx index f0d5078..29746d1 100644 --- a/react/src/App.tsx +++ b/react/src/App.tsx @@ -21,12 +21,12 @@ import React from 'react'; import { ThemeProvider, createTheme, StyledEngineProvider, Theme } from '@mui/material/styles'; import AppThemed from "./AppThemed"; -import {isDarkMode} from './DarkMode'; +import { isDarkMode } from './DarkMode'; declare module '@mui/material/styles' { interface Theme { - mainBackground: React.CSSProperties['color']; - toolbarColor: React.CSSProperties['color']; + mainBackground: React.CSSProperties['color']; + toolbarColor: React.CSSProperties['color']; } interface ThemeOptions { mainBackground?: React.CSSProperties['color']; @@ -35,12 +35,17 @@ declare module '@mui/material/styles' { } - +declare module '@mui/material/Button' { + interface ButtonPropsVariantOverrides { + dialogPrimary: true; + dialogSecondary: true; + } +} declare module '@mui/styles/defaultTheme' { - // eslint-disable-next-line @typescript-eslint/no-empty-interface - interface DefaultTheme extends Theme {} + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface DefaultTheme extends Theme { } } @@ -49,34 +54,71 @@ declare module '@mui/styles/defaultTheme' { const theme = createTheme( isDarkMode() ? { - palette: { - mode: 'dark', - primary: { - main: '#A770E4'// #6750A4" // #5B5690 #60529A #5C5694 + components: { + MuiButton: { + variants: [ + { + props: { variant: 'dialogPrimary' }, + style: { + color: "#FFFFFF" + } + }, + { + props: { variant: 'dialogSecondary', }, + style: { + color: "rgb(255,255,255,0.7)" + }, + }, + ], + }, }, - secondary: { - main: "#FF6060" + + palette: { + mode: 'dark', + primary: { + main: '#A770E4'// #6750A4" // #5B5690 #60529A #5C5694 + }, + secondary: { + main: "#FF6060" + }, }, - }, - mainBackground: "#222", - toolbarColor: '#FFFFFF' - } + mainBackground: "#222", + toolbarColor: '#222' + } : { - palette: { - primary: { - main: "#6750A4" // #5B5690 #60529A #5C5694 + components: { + MuiButton: { + variants: [ + { + props: { variant: 'dialogPrimary' }, + style: { + color: "rgb(0,0,0,0.87)" + } + }, + { + props: { variant: 'dialogSecondary', }, + style: { + color: "rgb(0,0,0,0.6)" + }, + }, + ], + }, }, - secondary: { - main: "#FF6060" - } + palette: { + primary: { + main: "#6750A4" // #5B5690 #60529A #5C5694 + }, + secondary: { + main: "#FF6060" + } - }, - mainBackground: "#FFFFFF", - toolbarColor: '#FFFFFF' + }, + mainBackground: "#FFFFFF", + toolbarColor: '#FFFFFF' - } + } ); @@ -97,7 +139,7 @@ const App = (class extends React.Component { return ( - + ); diff --git a/react/src/AppThemed.tsx b/react/src/AppThemed.tsx index 94b00b1..19965bc 100644 --- a/react/src/AppThemed.tsx +++ b/react/src/AppThemed.tsx @@ -879,7 +879,7 @@ render() { - diff --git a/react/src/BankDialog.tsx b/react/src/BankDialog.tsx index 8ceb2d4..eb61160 100644 --- a/react/src/BankDialog.tsx +++ b/react/src/BankDialog.tsx @@ -546,10 +546,10 @@ const BankDialog = withStyles(styles, { withTheme: true })( Are you sure you want to delete bank '{this.getSelectedBankName()}'? - - diff --git a/react/src/FilePropertyControl.tsx b/react/src/FilePropertyControl.tsx index 00e66ae..a3d987b 100644 --- a/react/src/FilePropertyControl.tsx +++ b/react/src/FilePropertyControl.tsx @@ -29,7 +29,7 @@ import createStyles from '@mui/styles/createStyles'; import withStyles from '@mui/styles/withStyles'; import { UiFileProperty } from './Lv2Plugin'; import Typography from '@mui/material/Typography'; -import { PiPedalModel, PiPedalModelFactory, ListenHandle} from './PiPedalModel'; +import { PiPedalModel, PiPedalModelFactory, ListenHandle,State} from './PiPedalModel'; import ButtonBase from '@mui/material/ButtonBase' import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; import {PedalboardItem} from './Pedalboard'; @@ -98,8 +98,18 @@ const FilePropertyControl = value: "", }; this.model = PiPedalModelFactory.getInstance(); + this.onStateChanged = this.onStateChanged.bind(this); } + + onStateChanged(state: State) { + if (this.mounted) + { + if (state === State.Ready) { + this.subscribeToPatchProperty(); + } + } + } private propertyGetHandle?: ListenHandle; monitorPropertyHandle?: ListenHandle; @@ -119,6 +129,7 @@ const FilePropertyControl = } } ); + } unsubscribeToPatchProperty() { if (this.monitorPropertyHandle !== undefined) @@ -126,15 +137,18 @@ const FilePropertyControl = this.model.cancelMonitorPatchProperty(this.monitorPropertyHandle); this.monitorPropertyHandle = undefined; } + } private mounted: boolean = false; componentDidMount() { + this.model.state.addOnChangedHandler(this.onStateChanged); this.mounted = true; this.subscribeToPatchProperty(); } componentWillUnmount() { this.unsubscribeToPatchProperty(); this.mounted = false; + this.model.state.removeOnChangedHandler(this.onStateChanged); } componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any): void { if (prevProps.fileProperty.patchProperty !== this.props.fileProperty.patchProperty diff --git a/react/src/FilePropertyDialog.tsx b/react/src/FilePropertyDialog.tsx index b430359..7c17ddb 100644 --- a/react/src/FilePropertyDialog.tsx +++ b/react/src/FilePropertyDialog.tsx @@ -18,19 +18,29 @@ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import React from 'react'; - -import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; - - +import { Theme, createStyles } from '@mui/material/styles'; +import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder'; +import RenameDialog from './RenameDialog'; +import Divider from '@mui/material/Divider'; +import MenuItem from '@mui/material/MenuItem'; +import Menu from '@mui/material/Menu'; +import MoreIcon from '@mui/icons-material/MoreVert'; +import { PiPedalModel, PiPedalModelFactory, FileEntry } from './PiPedalModel'; +import Link from '@mui/material/Link'; +import { isDarkMode } from './DarkMode'; import Button from '@mui/material/Button'; import FileUploadIcon from '@mui/icons-material/FileUpload'; import AudioFileIcon from '@mui/icons-material/AudioFile'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; - +import FolderIcon from '@mui/icons-material/Folder'; +import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; import DialogActions from '@mui/material/DialogActions'; import DialogTitle from '@mui/material/DialogTitle'; import IconButton from '@mui/material/IconButton'; import OldDeleteIcon from './OldDeleteIcon'; +import Toolbar from '@mui/material/Toolbar'; +import { withStyles, WithStyles } from '@mui/styles'; + import ResizeResponsiveComponent from './ResizeResponsiveComponent'; import { UiFileProperty } from './Lv2Plugin'; @@ -39,8 +49,20 @@ import Typography from '@mui/material/Typography'; import DialogEx from './DialogEx'; import UploadFileDialog from './UploadFileDialog'; import OkCancelDialog from './OkCancelDialog'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import HomeIcon from '@mui/icons-material/Home'; +import FilePropertyDirectorySelectDialog from './FilePropertyDirectorySelectDialog'; -export interface FilePropertyDialogProps { + + +const styles = (theme: Theme) => createStyles({ + secondaryText: { + color: theme.palette.text.secondary + }, +}); + + +export interface FilePropertyDialogProps extends WithStyles { open: boolean, fileProperty: UiFileProperty, selectedFile: string, @@ -51,16 +73,90 @@ export interface FilePropertyDialogProps { export interface FilePropertyDialogState { fullScreen: boolean; selectedFile: string; + navDirectory: string; hasSelection: boolean; - files: string[]; + canDelete: boolean; + fileEntries: FileEntry[]; columns: number; columnWidth: number; openUploadFileDialog: boolean; openConfirmDeleteDialog: boolean; + menuAnchorEl: null | HTMLElement; + newFolderDialogOpen: boolean; + renameDialogOpen: boolean; + moveDialogOpen: boolean; }; -export default class FilePropertyDialog extends ResizeResponsiveComponent { +function pathExtension(path: string) +{ + let dotPos = path.lastIndexOf('.'); + if (dotPos === -1) return ""; + let slashPos = path.lastIndexOf('/'); + if (slashPos !== -1) + { + if (dotPos <= slashPos+1) return ""; + } + return path.substring(dotPos); // include the '.'. + +} +function pathConcat(left: string, right: string) { + if (left === "") return right; + if (right === "") return left; + if (left.endsWith('/')) { + left = left.substring(0, left.length - 1); + } + if (right.startsWith("/")) { + right = right.substring(1); + } + return left + "/" + right; +} +export function pathFileNameOnly(path: string): string { + if (path === "..") return path; + let slashPos = path.lastIndexOf('/'); + if (slashPos < 0) { + slashPos = 0; + } else { + ++slashPos; + } + let extPos = path.lastIndexOf('.'); + if (extPos < 0 || extPos < slashPos) { + extPos = path.length; + } + + return path.substring(slashPos, extPos); +} + +export function pathFileName(path: string): string { + if (path === "..") return path; + let slashPos = path.lastIndexOf('/'); + if (slashPos < 0) { + slashPos = 0; + } else { + ++slashPos; + } + return path.substring(slashPos); +} + + +export default withStyles(styles, { withTheme: true })( + class FilePropertyDialog extends ResizeResponsiveComponent { + + getNavDirectoryFromFile(selectedFile: string, fileProperty: UiFileProperty): string { + + // would be easier if we had the data root (/var/pipedal/audio_downloads, but could be different when we're debugging. :-/ + let nPos = selectedFile.indexOf("/" + fileProperty.directory + "/"); + if (nPos === -1) return ""; + let relativePath = selectedFile.substring(nPos + fileProperty.directory.length + 2); + let segments = relativePath.split('/'); + + let result = ""; + for (let i = 0; i < segments.length - 1; ++i) { + if (result !== "") result += '/'; + result += segments[i]; + } + return result; + } constructor(props: FilePropertyDialogProps) { super(props); @@ -71,29 +167,32 @@ export default class FilePropertyDialog extends ResizeResponsiveComponent { if (this.mounted) { - files.splice(0,0,""); - this.setState({ files: files, hasSelection: this.isFileInList(files, this.state.selectedFile) }); + // let insertionPoint = files.length; + // for (let i = 0; i < files.length; ++i) { + // if (!files[i].isDirectory) { + // insertionPoint = i; + // break; + // } + // } + files.splice(0, 0, { filename: "", isDirectory: false }); + this.setState({ fileEntries: files, hasSelection: this.isFileInList(files, this.state.selectedFile), navDirectory: navPath }); } }).catch((error) => { this.model.showAlert(error.toString()) }); } - private lastDivRef : HTMLDivElement | null = null; + private lastDivRef: HTMLDivElement | null = null; - onMeasureRef(div: HTMLDivElement | null) - { + onMeasureRef(div: HTMLDivElement | null) { this.lastDivRef = div; if (div) { let width = div.offsetWidth; if (width === 0) return; - let columns = Math.floor((width-40)/280); - if(columns === 0) - { - columns = 1; - } - let columnWidth = (width-40)/columns; - if (columns !== this.state.columns || columnWidth !== this.state.columnWidth) - { - this.setState({columns: columns, columnWidth: columnWidth}); + let columns = 1; + let columnWidth = (width - 40) / columns; + if (columns !== this.state.columns || columnWidth !== this.state.columnWidth) { + this.setState({ columns: columns, columnWidth: columnWidth }); } } } onWindowSizeChanged(width: number, height: number): void { - this.setState({ - fullScreen: height < 200 + this.setState({ + fullScreen: height < 200 }) - if (this.lastDivRef !== null) - { + if (this.lastDivRef !== null) { this.onMeasureRef(this.lastDivRef); } @@ -166,45 +265,36 @@ export default class FilePropertyDialog extends ResizeResponsiveComponent, prevState: Readonly, snapshot?: any): void { super.componentDidUpdate?.(prevProps, prevState, snapshot); - if (prevProps.open !== this.props.open) - { - if (this.props.open) - { - this.requestFiles() + if (prevProps.open !== this.props.open || prevProps.fileProperty !== this.props.fileProperty || prevProps.selectedFile !== this.props.selectedFile) { + if (this.props.open) { + let navDirectory = this.getNavDirectoryFromFile(this.props.selectedFile, this.props.fileProperty); + this.setState({ + selectedFile: this.props.selectedFile, + newFolderDialogOpen: false, + renameDialogOpen: false, + moveDialogOpen: false + }); + this.requestFiles(navDirectory) this.requestScroll = true; - if (this.state.selectedFile !== this.props.selectedFile) - { - this.setState({selectedFile: this.props.selectedFile}); - } } - } else if (prevProps.fileProperty !== this.props.fileProperty) { - this.requestFiles() } } - private fileNameOnly(path: string): string { - let slashPos = path.lastIndexOf('/'); - if (slashPos < 0) { - slashPos = 0; - } else { - ++slashPos; + private isDirectory(path: string): boolean { + for (var fileEntry of this.state.fileEntries) { + if (fileEntry.filename === path) { + return fileEntry.isDirectory; + } } - let extPos = path.lastIndexOf('.'); - if (extPos < 0 || extPos < slashPos) { - extPos = path.length; - } - - return path.substring(slashPos, extPos); - + return false; } - - private isFileInList(files: string[], file: string) { + private isFileInList(files: FileEntry[], file: string) { let hasSelection = false; if (file === "") return true; for (var listFile of files) { - if (listFile === file) { + if (listFile.filename === file) { hasSelection = true; break; } @@ -214,113 +304,262 @@ export default class FilePropertyDialog extends ResizeResponsiveComponent) { + this.setState({ menuAnchorEl: event.currentTarget }); + } - handleConfirmDelete() - { - this.setState({openConfirmDeleteDialog: false}); - if (this.state.hasSelection) - { + handleMenuClose() { + this.setState({ menuAnchorEl: null }); + } + handleDelete() { + this.setState({ openConfirmDeleteDialog: true }); + } + handleConfirmDelete() { + this.setState({ openConfirmDeleteDialog: false }); + if (this.state.hasSelection) { let selectedFile = this.state.selectedFile; let position = -1; - for (let i = 0; i < this.state.files.length;++i) - { - let file = this.state.files[i]; - if (file === selectedFile) - { + for (let i = 0; i < this.state.fileEntries.length; ++i) { + let file = this.state.fileEntries[i]; + if (file.filename === selectedFile) { position = i; } } let newSelection = ""; - if (position >= 0 && position < this.state.files.length-1) - { - newSelection = this.state.files[position+1]; - } else if (position === this.state.files.length-1) - { + if (position >= 0 && position < this.state.fileEntries.length - 1) { + newSelection = this.state.fileEntries[position + 1].filename; + } else if (position === this.state.fileEntries.length - 1) { if (position !== 0) { - newSelection = this.state.files[position-1]; + newSelection = this.state.fileEntries[position - 1].filename; } } this.model.deleteUserFile(this.state.selectedFile) - .then( - ()=> { - this.setState({selectedFile: newSelection,hasSelection: newSelection !== ""}); - this.requestFiles(); - } - ).catch( - (e: any)=> - { - this.model.showAlert(e + ""); - } - ); + .then( + () => { + this.setState({ selectedFile: newSelection, hasSelection: newSelection !== "" }); + this.requestFiles(this.state.navDirectory); + } + ).catch( + (e: any) => { + this.model.showAlert(e + ""); + } + ); } } private wantsScrollRef: boolean = true; - mapKey: number = 0; - render() { - this.mapKey = 0; - let columnWidth = this.state.columnWidth; + navigate(relativeDirectory: string) { + this.requestFiles(relativeDirectory); + this.setState({ navDirectory: relativeDirectory }); + } + renderBreadcrumbs() { + if (this.state.navDirectory === "") { + return (
); + } + let breadcrumbs: React.ReactElement[] = [( + { this.navigate(""); }} + sx={{ display: 'flex', alignItems: 'center' }} + > + + Home + + ) + ]; + let directories = this.state.navDirectory.split("/"); + let target = ""; + for (let i = 0; i < directories.length - 1; ++i) { + target = pathConcat(target, directories[i]); + let myTarget = target; + breadcrumbs.push(( + { this.navigate(myTarget) }}> + {directories[i]} + + )); + } + { + let lastdirectory = directories[directories.length - 1]; + breadcrumbs.push(( + {lastdirectory} + )); + + } + return ( + + {breadcrumbs} + + ); + } + + getDefaultPath(): string { + try { + let storage = window.localStorage; + let result = storage.getItem("fpDefaultPath"); + if (result) + { + return result; + } + } catch (e) + { + + } + + return this.state.navDirectory; + } + setDefaultPath(path: string) { + try { + let storage = window.localStorage; + storage.setItem("fpDefaultPath",path); + } catch(e) { + + } + } + hasSelectedFileOrFolder(): boolean { + return this.state.hasSelection && this.state.selectedFile !== ""; + } + getFileExtensionList(uiFileProperty: UiFileProperty): string { + let result = ""; + for (var fileType of uiFileProperty.fileTypes) + { + if (fileType.fileExtension !== "" && fileType.fileExtension !== ".zip") + { + if (result !== "") result = result + ","; + result += fileType.fileExtension; + } + } + return result; + } + render() { + let classes = this.props.classes; + let columnWidth = this.state.columnWidth; return this.props.open && ( this.props.onCancel()} open={this.props.open} tag="fileProperty" fullWidth maxWidth="xl" style={{ height: "90%" }} PaperProps={{ style: { minHeight: "90%", maxHeight: "90%", overflowY: "visible" } }} > -
- { this.props.onCancel(); }} aria-label="back" + + { this.props.onCancel(); }} > - + - {this.props.fileProperty.label} + + {this.props.fileProperty.label} + + + { this.onNewFolder(); }} + > + + + + + { this.handleMenuOpen(ev); }} + > + + + { this.handleMenuClose(); }} + > + { this.handleMenuClose(); this.onNewFolder(); }}>New folder + {this.hasSelectedFileOrFolder() && ()} + {this.hasSelectedFileOrFolder() && ( { this.handleMenuClose();this.onMove(); }}>Move)} + {this.hasSelectedFileOrFolder() && ( { this.handleMenuClose();this.onRename(); }}>Rename)} + + +
+ {this.renderBreadcrumbs()}
-
 
+
 
-
this.onMeasureRef(element)} - style={{ flex: "1 1 100%", display: "flex", flexFlow: "row wrap", - justifyContent: "flex-start", alignContent: "flex-start", paddingLeft: 16, paddingTop: 16,paddingBottom: 16 }}> +
this.onMeasureRef(element)} + style={{ + flex: "1 1 100%", display: "flex", flexFlow: "row wrap", + justifyContent: "flex-start", alignContent: "flex-start", paddingLeft: 16, paddingTop: 16, paddingBottom: 16 + }}> { (this.state.columns !== 0) && // don't render until we have number of columns derived from layout. - this.state.files.map( - (value: string, index: number) => { - let displayValue = value; - if (displayValue === "") - { + this.state.fileEntries.map( + (value: FileEntry, index: number) => { + let isDefault = this.props.fileProperty.directory.startsWith("default"); + let displayValue = value.filename; + if (displayValue === "") { displayValue = ""; } else { - displayValue = this.fileNameOnly(value); + if (isDefault) + { + displayValue = pathFileName(displayValue); + } else { + displayValue = pathFileNameOnly(displayValue); + } } - let selected = value === this.state.selectedFile; + let selected = value.filename === this.state.selectedFile; let selectBg = selected ? "rgba(0,0,0,0.15)" : "rgba(0,0,0,0.0)"; + if (isDarkMode()) { + selectBg = selected ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.0)"; + } let scrollRef: ((element: HTMLDivElement | null) => void) | undefined = undefined; - if (selected) - { - scrollRef = (element)=> { this.onScrollRef(element)}; + if (selected) { + scrollRef = (element) => { this.onScrollRef(element) }; } return ( - this.onSelectValue(value)} onDoubleClick={()=> {this.onDoubleClickValue(value);}} + onClick={() => this.onSelectValue(value.filename)} onDoubleClick={() => { this.onDoubleClickValue(value.filename); }} >
- - {displayValue} + {value.filename === "" ? + () + : ( + value.isDirectory ? ( + + ) : ( + + ))} + {displayValue}
); @@ -333,25 +572,26 @@ export default class FilePropertyDialog extends ResizeResponsiveComponent
this.handleDelete()} > + disabled={!this.state.hasSelection || this.state.selectedFile === ""} + onClick={() => this.handleDelete()} >
 
- -
@@ -364,22 +604,160 @@ export default class FilePropertyDialog extends ResizeResponsiveComponentthis.handleConfirmDelete()} - onClose={()=>{ - this.setState({openConfirmDeleteDialog: false}); + onOk={() => this.handleConfirmDelete()} + onClose={() => { + this.setState({ openConfirmDeleteDialog: false }); }} /> + { + this.state.newFolderDialogOpen && ( + { this.setState({ newFolderDialogOpen: false }); this.onExecuteNewFolder(newName) }} + onClose={() => { this.setState({ newFolderDialogOpen: false }); }} + acceptActionName="OK" + /> + ) + } + { + this.state.renameDialogOpen && ( + { this.setState({ renameDialogOpen: false }); this.onExecuteRename(newName) }} + onClose={() => { this.setState({ renameDialogOpen: false }); }} + acceptActionName="OK" + /> + + ) + } + { + this.state.moveDialogOpen && ( + ( + {this.setState({moveDialogOpen: false});}} + onOk={ + (path) => { + this.setState({moveDialogOpen: false}); + this.setDefaultPath(path); + this.onExecuteMove(path); + } + } + + /> + ) + ) + } ); } -} \ No newline at end of file + openSelectedFile(): void { + if (this.isDirectory(this.state.selectedFile)) { + let directoryName = pathFileNameOnly(this.state.selectedFile); + let navDirectory = pathConcat(this.state.navDirectory, directoryName); + this.requestFiles(navDirectory); + this.setState({ navDirectory: navDirectory }); + } else { + this.props.onOk(this.props.fileProperty, this.state.selectedFile); + } + } + + private renameDefaultName(): string { + let name = this.state.selectedFile; + if (name === "") return ""; + if (this.isDirectory(name)) + { + return pathFileName(name); + } else { + return pathFileNameOnly(name); + } + } + private onMove(): void { + this.setState({ moveDialogOpen: true }); + } + private onExecuteMove(newDirectory: string) + { + let fileName = pathFileName(this.state.selectedFile); + let oldFilePath = pathConcat(this.state.navDirectory,fileName); + let newFilePath = pathConcat(newDirectory,fileName); + + this.model.renameFilePropertyFile(oldFilePath,newFilePath,this.props.fileProperty) + .then(()=>{ + this.requestFiles(this.state.navDirectory); + }) + .catch((e)=>{ + this.model.showAlert(e.toString()); + }); + + } + + private onRename(): void { + this.setState({ renameDialogOpen: true }); + } + private onExecuteRename(newName: string) { + let newPath: string = ""; + let oldPath: string = ""; + if (this.isDirectory(this.state.selectedFile)) + { + let oldName = pathFileName(this.state.selectedFile); + if (oldName === newName) return; + oldPath = pathConcat(this.state.navDirectory,oldName); + newPath = pathConcat(this.state.navDirectory,newName);; + } else { + let oldName = pathFileNameOnly(this.state.selectedFile); + if (oldName === newName) return; + let extension = pathExtension(this.state.selectedFile); + oldPath = pathConcat(this.state.navDirectory,oldName+extension); + newPath = pathConcat(this.state.navDirectory,newName + extension); + } + this.model.renameFilePropertyFile(oldPath,newPath,this.props.fileProperty) + .then((newPath)=>{ + this.setState({selectedFile: newPath}); + this.requestFiles(this.state.navDirectory); + this.requestScroll = true + + }) + .catch((e) =>{ + this.model.showAlert(e.toString()); + }); + } + + private onNewFolder(): void { + this.setState({ newFolderDialogOpen: true }); + } + + private onExecuteNewFolder(newName: string) { + this.model.createNewSampleDirectory(pathConcat(this.state.navDirectory,newName), this.props.fileProperty) + .then((newPath) => { + this.setState({selectedFile: newPath}); + this.requestFiles(this.state.navDirectory); + this.requestScroll = true + }) + .catch((e) => { + this.model.showAlert(e.toString()); + } + ); + } +}); \ No newline at end of file diff --git a/react/src/FilePropertyDirectorySelectDialog.tsx b/react/src/FilePropertyDirectorySelectDialog.tsx new file mode 100644 index 0000000..ec468c3 --- /dev/null +++ b/react/src/FilePropertyDirectorySelectDialog.tsx @@ -0,0 +1,347 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import React from 'react'; +import Button from '@mui/material/Button'; +import DialogEx from './DialogEx'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import ResizeResponsiveComponent from './ResizeResponsiveComponent'; +import ArrowRightIcon from '@mui/icons-material/ArrowRight'; +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; +import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; +import { UiFileProperty } from './Lv2Plugin'; +import FilePropertyDirectoryTree from './FilePropertyDirectoryTree'; +import ButtonBase from '@mui/material/ButtonBase'; +import Typography from '@mui/material/Typography' +import Divider from '@mui/material/Divider'; +import FolderIcon from '@mui/icons-material/Folder'; +import HomeIcon from '@mui/icons-material/Home'; +import { isDarkMode } from './DarkMode'; +import IconButton from '@mui/material/IconButton'; + + +function pathConcat(l: string, r: string): string +{ + if (l.length === 0) return r; + if (r.length === 0) return l; + return l +'/' + r; +} + +class DirectoryTree { + name: string = ""; + path: string = ""; + expanded: boolean = false; + children: DirectoryTree[] = []; + + constructor(tree: FilePropertyDirectoryTree, parentTree: (DirectoryTree | null) = null) { + this.name = tree.directoryName; + if (parentTree) { + this.path = pathConcat(parentTree.path,tree.directoryName); + } else { + this.name = "Home"; + this.path = tree.directoryName; + this.expanded = true; + } + for (var treeChild of tree.children) { + this.children.push(new DirectoryTree(treeChild, this)); + } + } + canExpand(): boolean { + return this.children.length !== 0; + } + expand(path: string): void { + this.expanded = true; + + let currentNode: DirectoryTree = this; + for (var segment of path.split('/')) { + let found = false; + for (var child of currentNode.children) { + if (child.name === segment) { + found = true; + if (child.children.length !== 0) { + child.expanded = true; + } + currentNode = child; + break; + } + } + if (!found) { + break; + } + } + } + private static remove_(directoryTree: DirectoryTree, path: string): boolean + { + for (let i = 0; i < directoryTree.children.length; ++i) + { + let child = directoryTree.children[i]; + if (child.path === path) + { + directoryTree.children.splice(i,1) + return true; + } + if (DirectoryTree.remove_(child,path)) + { + return true; + } + } + return false; + } + remove(path: string): boolean + { + return DirectoryTree.remove_(this,path); + } + find(path: string): DirectoryTree | null { + let currentNode: DirectoryTree = this; + for (var segment of path.split('/')) { + let found = false; + for (var child of currentNode.children) { + if (child.name === segment) { + found = true; + currentNode = child; + break; + } + } + if (!found) { + return null; + } + } + return currentNode; + + } +} + +export interface FilePropertyDirectorySelectDialogProps { + open: boolean, + uiFileProperty: UiFileProperty; + defaultPath: string, + excludeDirectory: string, + onOk: (path: string) => void, + onClose: () => void +}; + +export interface FilePropertyDirectorySelectDialogState { + fullScreen: boolean; + selectedPath: string; + directoryTree?: DirectoryTree; + hasSelection: boolean; + directoryTreeInvalidatecount: number; + +}; + +export default class FilePropertyDirectorySelectDialog extends ResizeResponsiveComponent { + + private model: PiPedalModel; + private refSelected: React.Ref; + + constructor(props: FilePropertyDirectorySelectDialogProps) { + super(props); + this.model = PiPedalModelFactory.getInstance(); + this.state = { + fullScreen: false, + selectedPath: props.defaultPath, + directoryTree: undefined, + hasSelection: false, + directoryTreeInvalidatecount: 0 + + }; + this.refSelected = React.createRef(); + + this.requestDirectoryTree(); + } + mounted: boolean = false; + + + + onWindowSizeChanged(width: number, height: number): void { + this.setState({ fullScreen: height < 200 }) + } + + + componentDidMount() { + super.componentDidMount(); + this.mounted = true; + } + componentWillUnmount() { + super.componentWillUnmount(); + this.mounted = false; + } + + private requestScroll = true; + requestDirectoryTree() { + if (!this.props.open) return; + + this.model.getFilePropertyDirectoryTree(this.props.uiFileProperty) + .then((filePropertyDirectoryTree) => { + let myTree = new DirectoryTree(filePropertyDirectoryTree); + if (this.props.excludeDirectory) + { + myTree.remove(this.props.excludeDirectory); + } + myTree.expand(this.state.selectedPath); + + let hasSelection = myTree.find(this.state.selectedPath) != null; + this.setState({ directoryTree: myTree, hasSelection: hasSelection }); + this.requestScroll = true; + }) + .catch((e) => { + this.model.showAlert(e.toString()); + this.setState({ hasSelection: false }); + }); + + } + + componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot: any): void { + super.componentDidUpdate?.(prevProps, prevState, snapshot); + + if (prevProps.open !== this.props.open) { + if (this.props.open) { + this.setState({ selectedPath: this.props.defaultPath, hasSelection: false }); + this.requestDirectoryTree(); + this.requestScroll = true; + } + } + } + + + onOK() { + if (this.state.hasSelection) { + this.props.onOk(this.state.selectedPath); + } + } + onClose() { + this.props.onClose(); + } + private onToggleExpand(directoryTree: DirectoryTree) + { + directoryTree.expanded = !directoryTree.expanded; + this.setState({directoryTreeInvalidatecount: this.state.directoryTreeInvalidatecount+1}); + } + private onTreeClick(directoryTree: DirectoryTree) { + if (!this.state.directoryTree) return; + this.state.directoryTree.expand(directoryTree.path); + this.setState({ selectedPath: directoryTree.path, hasSelection: true, directoryTreeInvalidatecount: this.state.directoryTreeInvalidatecount + 1 }); + } + private renderTree_(directoryTree: DirectoryTree) { + let ref: ((element: HTMLButtonElement | null) => void) | undefined = undefined; + let selected = directoryTree.path === this.state.selectedPath; + if (selected) { + ref = (element) => { + if (this.requestScroll) { + this.requestScroll = false; + } + + } + } + let selectBg = selected ? "rgba(0,0,0,0.15)" : "rgba(0,0,0,0.0)"; + if (isDarkMode()) { + selectBg = selected ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.0)"; + } + let showToggle = directoryTree.canExpand() && directoryTree.path.length !== 0; + return ( +
+
+ { this.onToggleExpand(directoryTree); }} + style={{opacity: showToggle ? 1: 0}} + disabled={!showToggle} + > + { + directoryTree.expanded && + ( + + ) + } + { + !directoryTree.expanded && + ( + + ) + } + + { this.onTreeClick(directoryTree); }}> +
+
+ { + directoryTree.path === "" ? ( + + ) : ( + + ) + } + + {directoryTree.name === "" ? "Home" : directoryTree.name} +
+ +
+ {directoryTree.expanded + && directoryTree.canExpand() + && ( +
+ { + directoryTree.children.map( + (child, index) => { + return this.renderTree_(child); + } + ) + } +
+ ) + } +
+ ); + } + private renderTree() { + if (!this.state.directoryTree) { return undefined; } + return this.renderTree_(this.state.directoryTree); + } + render() { + + + return ( + { this.onClose(); }} aria-labelledby="Rename-dialog-title" + fullScreen={this.state.fullScreen} + style={{ userSelect: "none" }} + PaperProps={{ style: { minHeight: "80%", maxHeight: "80%", minWidth: "75%", maxWidth: "75%", overflowY: "visible" } }} + > + + Select folder + + + +
+ { + this.renderTree() + } +
+
+ + + + + +
+ ); + } +} \ No newline at end of file diff --git a/react/src/FilePropertyDirectoryTree.tsx b/react/src/FilePropertyDirectoryTree.tsx new file mode 100644 index 0000000..4a9191b --- /dev/null +++ b/react/src/FilePropertyDirectoryTree.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +export default class FilePropertyDirectoryTree { + deserialize(input: any) : FilePropertyDirectoryTree { + this.directoryName = input.directoryName; + this.children = FilePropertyDirectoryTree.deserialize_array(input.children); + return this; + } + static deserialize_array(input: any): FilePropertyDirectoryTree[] { + let result: FilePropertyDirectoryTree[] = []; + for (let i = 0; i < input.length; ++i) + { + result[i] = new FilePropertyDirectoryTree().deserialize(input[i]); + } + return result; + } + + + directoryName: string = ""; + children: FilePropertyDirectoryTree[] = []; +} \ No newline at end of file diff --git a/react/src/JackServerSettingsDialog.tsx b/react/src/JackServerSettingsDialog.tsx index 34b3fec..38c811b 100644 --- a/react/src/JackServerSettingsDialog.tsx +++ b/react/src/JackServerSettingsDialog.tsx @@ -362,10 +362,10 @@ const JackServerSettingsDialog = withStyles(styles)( - - diff --git a/react/src/ListSelectDialog.tsx b/react/src/ListSelectDialog.tsx index 0124104..03f145d 100644 --- a/react/src/ListSelectDialog.tsx +++ b/react/src/ListSelectDialog.tsx @@ -106,7 +106,7 @@ export default class ListSelectDialog extends ResizeResponsiveComponent - diff --git a/react/src/LoadPluginDialog.tsx b/react/src/LoadPluginDialog.tsx index 5c7c93b..fea41e4 100644 --- a/react/src/LoadPluginDialog.tsx +++ b/react/src/LoadPluginDialog.tsx @@ -814,8 +814,8 @@ export const LoadPluginDialog =
- - + +
@@ -839,8 +839,8 @@ export const LoadPluginDialog =
- - + +
diff --git a/react/src/Lv2Plugin.tsx b/react/src/Lv2Plugin.tsx index 4ec695f..2204183 100644 --- a/react/src/Lv2Plugin.tsx +++ b/react/src/Lv2Plugin.tsx @@ -250,6 +250,7 @@ export class UiFileProperty { this.directory = input.directory; this.index = input.index; this.portGroup = input.portGroup; + this.resourceDirectory = input.resourceDirectory??""; return this; } static deserialize_array(input: any): UiFileProperty[] @@ -303,6 +304,7 @@ export class UiFileProperty { directory: string = ""; index: number = -1; portGroup: string = ""; + resourceDirectory: string = ""; }; export class Lv2Plugin implements Deserializable { diff --git a/react/src/OkCancelDialog.tsx b/react/src/OkCancelDialog.tsx index 72f8ab4..726ac3b 100644 --- a/react/src/OkCancelDialog.tsx +++ b/react/src/OkCancelDialog.tsx @@ -70,10 +70,10 @@ export default class OkCancelDialog extends React.Component{text} - - diff --git a/react/src/PiPedalModel.tsx b/react/src/PiPedalModel.tsx index 3b98ccf..9a90a1b 100644 --- a/react/src/PiPedalModel.tsx +++ b/react/src/PiPedalModel.tsx @@ -39,6 +39,7 @@ import WifiChannel from './WifiChannel'; import AlsaDeviceInfo from './AlsaDeviceInfo'; import { AndroidHostInterface, FakeAndroidHost } from './AndroidHost'; import {ColorTheme, getColorScheme,setColorScheme} from './DarkMode'; +import FilePropertyDirectoryTree from './FilePropertyDirectoryTree'; export enum State { @@ -52,6 +53,10 @@ export enum State { export type ControlValueChangedHandler = (key: string, value: number) => void; +export interface FileEntry { + filename: string; + isDirectory: boolean; +}; export type PluginPresetsChangedHandler = (pluginUri: string) => void; @@ -337,7 +342,7 @@ interface Vst3ControlChangedBody { export interface FavoritesList { [url: string]: boolean; -} +}; export class PiPedalModel //implements PiPedalModel @@ -626,25 +631,6 @@ export class PiPedalModel //implements PiPedalModel - requestPluginClasses(): Promise { - const myRequest = new Request(this.varRequest('plugin_classes.json')); - return fetch(myRequest) - .then( - response => response.json() - ) - .then(data => { - let plugins = new PluginClass().deserialize(data); - - this.validatePlugins(plugins); - this.plugin_classes.set(plugins); - return true; - }) - .catch((error) => { - this.setError("Can't contact server.\n\n" + error); - return false; - }); - - } getWebSocket(): PiPedalSocket { if (this.webSocket === undefined) { throw new PiPedalStateError("Attempt to access web socket before it's connected."); @@ -653,6 +639,11 @@ export class PiPedalModel //implements PiPedalModel } onSocketConnectionLost() { + // remove all the events and subscriptions we have. + // yyy + this.vuSubscriptions = []; + this.monitorPatchPropertyListeners = []; + if (this.isAndroidHosted()) { this.androidHost?.setDisconnected(true); } @@ -756,8 +747,8 @@ export class PiPedalModel //implements PiPedalModel } - maxFileUploadSize: number = 100000000; - maxPresetUploadSize: number = 512 * 1024; + maxFileUploadSize: number = 512*1024*1024; + maxPresetUploadSize: number = 1024 * 1024; debug: boolean = false; @@ -777,7 +768,7 @@ export class PiPedalModel //implements PiPedalModel this.androidHost = new FakeAndroidHost(); } this.debug = !!data.debug; - let { socket_server_port, socket_server_address } = data; + let { socket_server_port, socket_server_address,max_upload_size } = data; if ((!socket_server_address) || socket_server_address === "*") { socket_server_address = window.location.hostname; } @@ -788,6 +779,7 @@ export class PiPedalModel //implements PiPedalModel this.socketServerUrl = socket_server; this.varServerUrl = var_server_url; + this.maxFileUploadSize = parseInt(max_upload_size); this.webSocket = new PiPedalSocket( this.socketServerUrl, @@ -918,6 +910,7 @@ export class PiPedalModel //implements PiPedalModel // MUST not allow reconnect until at least one complete load has finished. this.webSocket.canReconnect = true; } + this.setState(State.Ready); return true; }) .catch((error) => { @@ -929,20 +922,6 @@ export class PiPedalModel //implements PiPedalModel ; } - requestCurrentPedalboard(): Promise { - const myRequest = new Request(this.varRequest('current_pedalboard.json')); - return fetch(myRequest) - .then( - (response) => { - return response.json(); - } - ) - .then(data => { - let pedalboard = new Pedalboard().deserialize(data); - pedalboard.ensurePedalboardIds(); - this.pedalboard.set(pedalboard); - }); - } onError(msg: string): void { this.errorMessage.set(msg); @@ -1630,14 +1609,21 @@ export class PiPedalModel //implements PiPedalModel }); } + // deprecated. requestFileList(piPedalFileProperty: UiFileProperty): Promise { return nullCast(this.webSocket) .request('requestFileList', piPedalFileProperty); } - + requestFileList2(relativeDirectoryPath: string,piPedalFileProperty: UiFileProperty): Promise { + return nullCast(this.webSocket) + .request('requestFileList2', + {relativePath: relativeDirectoryPath, fileProperty: piPedalFileProperty} + ); + } + deleteUserFile(fileName: string) : Promise { - return nullCast(this.webSocket).request('deleteUserFile',fileName) + return nullCast(this.webSocket).request('deleteUserFile',fileName); } @@ -2053,10 +2039,10 @@ export class PiPedalModel //implements PiPedalModel for (let i = 0; i < this.monitorPatchPropertyListeners.length; ++i) { if (this.monitorPatchPropertyListeners[i].handle === listenHandle._handle) { this.monitorPatchPropertyListeners.splice(i, 1); + this.webSocket?.send("cancelMonitorPatchProperty", listenHandle._handle); break; } } - this.webSocket?.send("cancelMonitorPatchProperty", listenHandle._handle); } @@ -2247,7 +2233,11 @@ export class PiPedalModel //implements PiPedalModel return new Promise((resolve, reject) => { let ws = this.webSocket; - if (!ws) return; + if (!ws) + { + resolve(); + return; + } ws.request( "setGovernorSettings", governor @@ -2260,6 +2250,74 @@ export class PiPedalModel //implements PiPedalModel }); }); } + createNewSampleDirectory(relativePath: string, uiFileProperty: UiFileProperty) : Promise + { + return new Promise((resolve, reject) => { + + let ws = this.webSocket; + if (!ws) { + resolve(""); + return; + } + ws.request( + "createNewSampleDirectory", + { + relativePath: relativePath, + uiFileProperty: uiFileProperty + } + ) + .then((newPath) => { + resolve(newPath); + }) + .catch((err) => { + reject(err); + }); + }); + } + + getFilePropertyDirectoryTree(uiFileProperty: UiFileProperty) : Promise { + return new Promise((resolve, reject) => { + let ws = this.webSocket; + if (!ws) { + resolve(new FilePropertyDirectoryTree()); + return; + } + ws.request( + "getFilePropertyDirectoryTree", + uiFileProperty + ).then((result)=> { + resolve(new FilePropertyDirectoryTree().deserialize(result)); + }).catch((e) => { + reject(e); + }); + }); + } + renameFilePropertyFile(oldRelativePath: string, newRelativePath: string, uiFileProperty: UiFileProperty) : Promise + { + return new Promise((resolve, reject) => { + + let ws = this.webSocket; + if (!ws) { + resolve(""); + return; + } + ws.request( + "renameFilePropertyFile", + { + oldRelativePath: oldRelativePath, + newRelativePath: newRelativePath, + uiFileProperty: uiFileProperty + } + ) + .then((newPath) => { + resolve(newPath); + }) + .catch((err) => { + reject(err); + }); + }); + } + setWifiConfigSettings(wifiConfigSettings: WifiConfigSettings): Promise { let result = new Promise((resolve, reject) => { let oldSettings = this.wifiConfigSettings.get(); @@ -2362,7 +2420,6 @@ export class PiPedalModel //implements PiPedalModel .catch((err) => { //resolve(); }); - // yyy resolve(); }); @@ -2555,8 +2612,11 @@ export class PiPedalModel //implements PiPedalModel if (this.getTheme() !== value) { setColorScheme(value); - this.reloadPage(); - } + setTimeout(()=>{ + this.reloadPage(); + }, + 200); +} } reloadRequested: boolean = false; @@ -2564,10 +2624,11 @@ export class PiPedalModel //implements PiPedalModel reloadPage() { this.reloadRequested = true; // eslint-disable-next-line no-restricted-globals - window.location.reload(); + let url = window.location.href.split('#')[0]; + window.location.href = url; + //window.location.reload(); } - }; let instance: PiPedalModel | undefined = undefined; diff --git a/react/src/PluginControlView.tsx b/react/src/PluginControlView.tsx index 52b5233..20f5283 100644 --- a/react/src/PluginControlView.tsx +++ b/react/src/PluginControlView.tsx @@ -663,7 +663,7 @@ const PluginControlView = }) .catch((error) => { - this.model.showAlert("setPatchProperty failed: " + error); + this.model.showAlert("Unable to complete the operation. Audio is not running." + error); }); this.setState({ showFileDialog: false }); } diff --git a/react/src/PluginInfoDialog.tsx b/react/src/PluginInfoDialog.tsx index 465361a..2e9bfca 100644 --- a/react/src/PluginInfoDialog.tsx +++ b/react/src/PluginInfoDialog.tsx @@ -317,7 +317,7 @@ const PluginInfoDialog = withStyles(styles)((props: PluginInfoProps) => {
- diff --git a/react/src/RenameDialog.tsx b/react/src/RenameDialog.tsx index a99c631..2cb82bc 100644 --- a/react/src/RenameDialog.tsx +++ b/react/src/RenameDialog.tsx @@ -25,6 +25,7 @@ import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import { nullCast } from './Utility'; import ResizeResponsiveComponent from './ResizeResponsiveComponent'; +import {PiPedalModel,PiPedalModelFactory} from './PiPedalModel'; export interface RenameDialogProps { @@ -74,6 +75,12 @@ export default class RenameDialog extends ResizeResponsiveComponent { let text = nullCast(this.refText.current).value; text = text.trim(); + try { + this.checkForIllegalCharacters(text); + } catch (e:any) + { + let model:PiPedalModel = PiPedalModelFactory.getInstance(); + model.showAlert(e.toString()); + return; + } if (text.length === 0) return; onOk(text); } @@ -116,10 +131,10 @@ export default class RenameDialog extends ResizeResponsiveComponent - - diff --git a/react/src/SelectChannelsDialog.tsx b/react/src/SelectChannelsDialog.tsx index de9edd6..ccabcd2 100644 --- a/react/src/SelectChannelsDialog.tsx +++ b/react/src/SelectChannelsDialog.tsx @@ -107,10 +107,10 @@ function SelectChannelsDialog(props: SelectChannelsDialogProps) { )} - - diff --git a/react/src/SelectMidiChannelsDialog.tsx b/react/src/SelectMidiChannelsDialog.tsx index bd51194..df621bc 100644 --- a/react/src/SelectMidiChannelsDialog.tsx +++ b/react/src/SelectMidiChannelsDialog.tsx @@ -110,10 +110,10 @@ function SelectMidiChannelsDialog(props: SelectMidiChannelsDialogProps) { )} - - diff --git a/react/src/SelectThemeDialog.tsx b/react/src/SelectThemeDialog.tsx index f928663..c4db808 100644 --- a/react/src/SelectThemeDialog.tsx +++ b/react/src/SelectThemeDialog.tsx @@ -79,10 +79,10 @@ function SelectThemesDialog(props: SelectThemesDialogProps) { - - diff --git a/react/src/SettingsDialog.tsx b/react/src/SettingsDialog.tsx index 53f5da5..bd1d863 100644 --- a/react/src/SettingsDialog.tsx +++ b/react/src/SettingsDialog.tsx @@ -885,7 +885,7 @@ const SettingsDialog = withStyles(styles, { withTheme: true })( onClose={()=> { this.setState({showThemeSelectDialog: false});} } onOk={(selectedTheme: ColorTheme) => { this.model.setTheme(selectedTheme); - this.setState({showThemeSelectDialog: false}); + this.setState({showThemeSelectDialog: false}); }} defaultTheme={this.model.getTheme()} /> diff --git a/react/src/UploadFileDialog.tsx b/react/src/UploadFileDialog.tsx index f6b0d72..368432d 100644 --- a/react/src/UploadFileDialog.tsx +++ b/react/src/UploadFileDialog.tsx @@ -208,7 +208,11 @@ export default class UploadFileDialog extends ResizeResponsiveComponent ) : ( - diff --git a/react/src/UploadPresetDialog.tsx b/react/src/UploadPresetDialog.tsx index 9381473..073179c 100644 --- a/react/src/UploadPresetDialog.tsx +++ b/react/src/UploadPresetDialog.tsx @@ -182,11 +182,11 @@ export default class UploadPresetDialog extends ResizeResponsiveComponent - - @@ -356,13 +356,13 @@ const WifiConfigDialog = withStyles(styles, { withTheme: true })( - diff --git a/react/src/WifiDirectConfigDialog.tsx b/react/src/WifiDirectConfigDialog.tsx index 1402c08..50bad7a 100644 --- a/react/src/WifiDirectConfigDialog.tsx +++ b/react/src/WifiDirectConfigDialog.tsx @@ -506,10 +506,10 @@ const WifiDirectConfigDialog = withStyles(styles, { withTheme: true })( )} - - diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f0b5dd3..6a23245 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -62,6 +62,7 @@ else() message(STATUS "LILV_0_INCLUDE_DIRS: ${LILV_0_INCLUDE_DIRS}") endif() +pkg_check_modules(ZIP "lilv-0") # Use LV2 headers from the /usr/lib directory. #set (LV2DEV_INCLUDE_DIRS /usr/lib) @@ -131,6 +132,12 @@ else() endif() set (PIPEDAL_SOURCES + WebServerConfig.cpp WebServerConfig.hpp + Finally.hpp + ZipFile.cpp ZipFile.hpp + TemporaryFile.cpp TemporaryFile.hpp + FilePropertyDirectoryTree.cpp FilePropertyDirectoryTree.hpp + FileEntry.cpp FileEntry.hpp atom_object.hpp atom_object.cpp FileBrowserFiles.h FileBrowserFilesFeature.hpp FileBrowserFilesFeature.cpp @@ -227,7 +234,7 @@ set (PIPEDAL_INCLUDES . ) -set(PIPEDAL_LIBS libpipedald +set(PIPEDAL_LIBS libpipedald zip pthread atomic stdc++fs asound avahi-common avahi-client systemd ${VST3_LIBRARIES} ${LILV_0_LIBRARIES} diff --git a/src/FileBrowserFiles.h b/src/FileBrowserFiles.h index a474f1b..50687d4 100644 --- a/src/FileBrowserFiles.h +++ b/src/FileBrowserFiles.h @@ -111,7 +111,7 @@ typedef struct { get_upload_path returns the supplied path unmodified. ` If the filename is a child of rootResourceDirectory, creates a corresponding link to the file in the file browser - directory and returns the name of that link. + directory and returns the path of that link. The caller must free memory for the returned value with `LV2_FileBrowser_Files.free_path()`. diff --git a/src/FileEntry.cpp b/src/FileEntry.cpp new file mode 100644 index 0000000..f9e787e --- /dev/null +++ b/src/FileEntry.cpp @@ -0,0 +1,28 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#include "FileEntry.hpp" + +using namespace pipedal; + +JSON_MAP_BEGIN(FileEntry) + JSON_MAP_REFERENCE(FileEntry,filename) + JSON_MAP_REFERENCE(FileEntry,isDirectory) + +JSON_MAP_END() diff --git a/src/FileEntry.hpp b/src/FileEntry.hpp new file mode 100644 index 0000000..0acf5be --- /dev/null +++ b/src/FileEntry.hpp @@ -0,0 +1,40 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#pragma once + +#include "json.hpp" + +namespace pipedal { + + class FileEntry { + public: + FileEntry() { } + FileEntry(std::string filename,bool isDirectory) + :filename_(filename), isDirectory_(isDirectory) + { + + } + std::string filename_; + bool isDirectory_ = false; + + DECLARE_JSON_MAP(FileEntry); + + }; +} \ No newline at end of file diff --git a/src/FilePropertyDirectoryTree.cpp b/src/FilePropertyDirectoryTree.cpp new file mode 100644 index 0000000..d6fafdd --- /dev/null +++ b/src/FilePropertyDirectoryTree.cpp @@ -0,0 +1,35 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#include "FilePropertyDirectoryTree.hpp" + +using namespace pipedal; + +FilePropertyDirectoryTree::FilePropertyDirectoryTree() +{ +} +FilePropertyDirectoryTree::FilePropertyDirectoryTree(const std::string &directoryName) + : directoryName_(directoryName) +{ +} + +JSON_MAP_BEGIN(FilePropertyDirectoryTree) +JSON_MAP_REFERENCE(FilePropertyDirectoryTree, directoryName) +JSON_MAP_REFERENCE(FilePropertyDirectoryTree, children) +JSON_MAP_END() diff --git a/src/FilePropertyDirectoryTree.hpp b/src/FilePropertyDirectoryTree.hpp new file mode 100644 index 0000000..4ab8c4d --- /dev/null +++ b/src/FilePropertyDirectoryTree.hpp @@ -0,0 +1,37 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#pragma once +#include "json.hpp" +#include +#include +#include +namespace pipedal { + class FilePropertyDirectoryTree { + public: + using ptr = std::unique_ptr; + + FilePropertyDirectoryTree(); + FilePropertyDirectoryTree(const std::string&directoryName); + std::string directoryName_; + std::vector> children_; + + DECLARE_JSON_MAP(FilePropertyDirectoryTree); + }; +} diff --git a/src/Finally.hpp b/src/Finally.hpp new file mode 100644 index 0000000..cbbdc7e --- /dev/null +++ b/src/Finally.hpp @@ -0,0 +1,39 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#pragma once +#include + +namespace pipedal { + + class Finally { + public: + Finally(std::function &&fn) + :fn (std::move(fn)) + { + + } + ~Finally() { + fn(); + } + private: + std::function fn; + }; + +} \ No newline at end of file diff --git a/src/PiPedalModel.cpp b/src/PiPedalModel.cpp index 59c0bc2..1c5eed8 100644 --- a/src/PiPedalModel.cpp +++ b/src/PiPedalModel.cpp @@ -138,7 +138,7 @@ void PiPedalModel::Init(const PiPedalConfiguration &configuration) storage.SetConfigRoot(configuration.GetDocRoot()); storage.SetDataRoot(configuration.GetLocalStoragePath()); storage.Initialize(); - lv2Host.SetPluginStoragePath(storage.GetPluginAudioFileDirectory()); + lv2Host.SetPluginStoragePath(storage.GetPluginUploadDirectory()); this->systemMidiBindings = storage.GetSystemMidiBindings(); @@ -1151,6 +1151,7 @@ void PiPedalModel::RestartAudio() // do a complete reload. + this->audioHost->SetPedalboard(nullptr); this->jackConfiguration.AlsaInitialize(this->jackServerSettings); @@ -2039,6 +2040,27 @@ std::vector PiPedalModel::GetFileList(const UiFileProperty &filePro return std::vector(); // don't disclose to users what the problem is. } } +std::vector PiPedalModel::GetFileList2(const std::string &relativePath,const UiFileProperty &fileProperty) +{ + try + { + return this->storage.GetFileList2(relativePath,fileProperty); + } + catch (const std::exception &e) + { + Lv2Log::warning("GetFileList() failed: (%s)", e.what()); + return std::vector(); // don't disclose to users what the problem is. + } +} + +std::string PiPedalModel::RenameFilePropertyFile( + const std::string&oldRelativePath, + const std::string&newRelativePath, + const UiFileProperty&uiFileProperty) +{ + std::lock_guard lock(mutex); + return storage.RenameFilePropertyFile(oldRelativePath,newRelativePath,uiFileProperty); +} void PiPedalModel::DeleteSampleFile(const std::filesystem::path &fileName) { @@ -2046,9 +2068,23 @@ void PiPedalModel::DeleteSampleFile(const std::filesystem::path &fileName) storage.DeleteSampleFile(fileName); } -std::string PiPedalModel::UploadUserFile(const std::string &directory, const std::string &patchProperty, const std::string &filename, const std::string &fileBody) +std::string PiPedalModel::CreateNewSampleDirectory(const std::string&relativePath, const UiFileProperty&uiFileProperty) { - return storage.UploadUserFile(directory, patchProperty, filename, fileBody); + std::lock_guard lock(mutex); + return storage.CreateNewSampleDirectory(relativePath, uiFileProperty); + +} +FilePropertyDirectoryTree::ptr PiPedalModel::GetFilePropertydirectoryTree(const UiFileProperty&uiFileProperty) +{ + std::lock_guard lock(mutex); + return storage.GetFilePropertydirectoryTree(uiFileProperty); + +} + + +std::string PiPedalModel::UploadUserFile(const std::string &directory, const std::string &patchProperty, const std::string &filename, std::istream&stream, size_t contentLength) +{ + return storage.UploadUserFile(directory, patchProperty, filename, stream,contentLength); } uint64_t PiPedalModel::CreateNewPreset() @@ -2058,6 +2094,17 @@ uint64_t PiPedalModel::CreateNewPreset() return storage.CreateNewPreset(); } +void PiPedalModel::CheckForResourceInitialization(Pedalboard &pedalboard) +{ + for (auto item: pedalboard.GetAllPlugins()) + { + if (!item->isSplit()) + { + lv2Host.CheckForResourceInitialization(item->uri(),storage.GetPluginUploadDirectory()); + } + + } +} bool PiPedalModel::LoadCurrentPedalboard() { Lv2PedalboardErrorList errorMessages; @@ -2066,6 +2113,7 @@ bool PiPedalModel::LoadCurrentPedalboard() // apply the error messages to the lv2Pedalboard. // return true if the error messages have changed + CheckForResourceInitialization(this->pedalboard); audioHost->SetPedalboard(lv2Pedalboard); return true; } @@ -2087,3 +2135,7 @@ void PiPedalModel::OnNotifyLv2RealtimeError(int64_t instanceId, const std::strin } delete[] t; } +std::filesystem::path PiPedalModel::GetPluginUploadDirectory() const +{ + return storage.GetPluginUploadDirectory(); +} \ No newline at end of file diff --git a/src/PiPedalModel.hpp b/src/PiPedalModel.hpp index cbe74fb..95bdba4 100644 --- a/src/PiPedalModel.hpp +++ b/src/PiPedalModel.hpp @@ -39,6 +39,7 @@ #include #include "Promise.hpp" #include "AtomConverter.hpp" +#include "FileEntry.hpp" namespace pipedal { @@ -189,12 +190,13 @@ namespace pipedal PiPedalConfiguration configuration; - + void CheckForResourceInitialization(Pedalboard &pedalboard); public: PiPedalModel(); virtual ~PiPedalModel(); uint16_t GetWebPort() const { return webPort; } + std::filesystem::path GetPluginUploadDirectory() const; void Close(); void SetOnboarding(bool value); @@ -328,9 +330,14 @@ namespace pipedal void SetFavorites(const std::map &favorites); std::vector GetFileList(const UiFileProperty&fileProperty); + std::vector GetFileList2(const std::string &relativePath,const UiFileProperty&fileProperty); void DeleteSampleFile(const std::filesystem::path &fileName); - std::string UploadUserFile(const std::string &directory, const std::string &patchProperty,const std::string&filename,const std::string&fileBody); + std::string CreateNewSampleDirectory(const std::string&relativePath, const UiFileProperty&uiFileProperty); + std::string RenameFilePropertyFile(const std::string&oldRelativePath,const std::string&newRelativePath,const UiFileProperty&uiFileProperty); + FilePropertyDirectoryTree::ptr GetFilePropertydirectoryTree(const UiFileProperty&uiFileProperty); + + std::string UploadUserFile(const std::string &directory, const std::string &patchProperty,const std::string&filename,std::istream&inputStream,size_t streamLength); uint64_t CreateNewPreset(); bool LoadCurrentPedalboard(); diff --git a/src/PiPedalSocket.cpp b/src/PiPedalSocket.cpp index 3ab0abd..4c6cadb 100644 --- a/src/PiPedalSocket.cpp +++ b/src/PiPedalSocket.cpp @@ -40,6 +40,7 @@ #include "SysExec.hpp" #include "PiPedalAlsa.hpp" #include +#include "FileEntry.hpp" using namespace std; using namespace pipedal; @@ -58,6 +59,33 @@ JSON_MAP_REFERENCE(GetPatchPropertyBody, propertyUri) JSON_MAP_END() +class CreateNewSampleDirectoryArgs { +public: + std::string relativePath_; + UiFileProperty uiFileProperty_; + DECLARE_JSON_MAP(CreateNewSampleDirectoryArgs); +}; + +JSON_MAP_BEGIN(CreateNewSampleDirectoryArgs) +JSON_MAP_REFERENCE(CreateNewSampleDirectoryArgs, relativePath) +JSON_MAP_REFERENCE(CreateNewSampleDirectoryArgs, uiFileProperty) +JSON_MAP_END() + +class RenameSampleFileArgs { +public: + std::string oldRelativePath_; + std::string newRelativePath_; + UiFileProperty uiFileProperty_; + DECLARE_JSON_MAP(RenameSampleFileArgs); +}; + +JSON_MAP_BEGIN(RenameSampleFileArgs) +JSON_MAP_REFERENCE(RenameSampleFileArgs, oldRelativePath) +JSON_MAP_REFERENCE(RenameSampleFileArgs, newRelativePath) +JSON_MAP_REFERENCE(RenameSampleFileArgs, uiFileProperty) +JSON_MAP_END() + + class Lv2StateChangedBody { public: uint64_t instanceId_; @@ -198,6 +226,21 @@ JSON_MAP_REFERENCE(MonitorResultBody, subscriptionHandle) JSON_MAP_REFERENCE(MonitorResultBody, value) JSON_MAP_END() +class FileRequestArgs +{ +public: + std::string relativePath_; + UiFileProperty fileProperty_; + + DECLARE_JSON_MAP(FileRequestArgs); +}; +JSON_MAP_BEGIN(FileRequestArgs) +JSON_MAP_REFERENCE(FileRequestArgs, relativePath) +JSON_MAP_REFERENCE(FileRequestArgs, fileProperty) +JSON_MAP_END() + + + class MonitorPortBody { @@ -1417,6 +1460,13 @@ public: std::vector list = this->model.GetFileList(fileProperty); this->Reply(replyTo,"requestFileList",list); } + else if (message == "requestFileList2") + { + FileRequestArgs requestArgs; + pReader->read(&requestArgs); + std::vector list = this->model.GetFileList2(requestArgs.relativePath_, requestArgs.fileProperty_); + this->Reply(replyTo,"requestFileList2",list); + } else if (message == "newPreset") { int64_t presetId = this->model.CreateNewPreset(); @@ -1430,6 +1480,29 @@ public: this->model.DeleteSampleFile(fileName); this->Reply(replyTo,"deleteUserFile",true); } + else if (message == "createNewSampleDirectory") + { + CreateNewSampleDirectoryArgs args; + pReader->read(&args); + + std::string newFileName = this->model.CreateNewSampleDirectory(args.relativePath_,args.uiFileProperty_); + this->Reply(replyTo,"createNewSampleDirectory",newFileName); + } + else if (message == "renameFilePropertyFile") + { + RenameSampleFileArgs args; + pReader->read(&args); + + std::string newFileName = this->model.RenameFilePropertyFile(args.oldRelativePath_,args.newRelativePath_,args.uiFileProperty_); + this->Reply(replyTo,"renameFilePropertyFile",newFileName); + } else if (message == "getFilePropertyDirectoryTree"){ + UiFileProperty uiFileProperty; + pReader->read(&uiFileProperty); + FilePropertyDirectoryTree::ptr result = model.GetFilePropertydirectoryTree(uiFileProperty); + this->Reply(replyTo,"GetFilePropertydirectoryTree",result); + + } + else if (message == "setOnboarding") { bool value; diff --git a/src/PiPedalUI.cpp b/src/PiPedalUI.cpp index 7b7a295..e840c20 100644 --- a/src/PiPedalUI.cpp +++ b/src/PiPedalUI.cpp @@ -201,7 +201,15 @@ UiFileProperty::UiFileProperty(PluginHost *pHost, const LilvNode *node, const st { this->portGroup_ = portGroup.AsUri(); } - + AutoLilvNode resourceDirectory = lilv_world_get(pWorld,node,pHost->lilvUris->pipedalUI__resourceDirectory,nullptr); + if (resourceDirectory) + { + this->resourceDirectory_ = resourceDirectory.AsString(); + } + if (this->resourceDirectory_.empty()) + { + this->resourceDirectory_ = "default"; + } this->fileTypes_ = UiFileType::GetArray(pHost, node, pHost->lilvUris->pipedalUI__fileTypes); } @@ -460,6 +468,7 @@ JSON_MAP_REFERENCE(UiFileProperty, directory) JSON_MAP_REFERENCE(UiFileProperty, patchProperty) JSON_MAP_REFERENCE(UiFileProperty, fileTypes) JSON_MAP_REFERENCE(UiFileProperty, portGroup) +JSON_MAP_REFERENCE(UiFileProperty, resourceDirectory) JSON_MAP_END() JSON_MAP_BEGIN(UiFrequencyPlot) diff --git a/src/PiPedalUI.hpp b/src/PiPedalUI.hpp index ce6c13f..6c99527 100644 --- a/src/PiPedalUI.hpp +++ b/src/PiPedalUI.hpp @@ -42,6 +42,7 @@ #define PIPEDAL_UI__patchProperty PIPEDAL_UI_PREFIX "patchProperty" #define PIPEDAL_UI__directory PIPEDAL_UI_PREFIX "directory" #define PIPEDAL_UI__fileTypes PIPEDAL_UI_PREFIX "fileTypes" +#define PIPEDAL_UI__resourceDirectory PIPEDAL_UI_PREFIX "resourceDirectory" #define PIPEDAL_UI__fileType PIPEDAL_UI_PREFIX "fileType" #define PIPEDAL_UI__fileExtension PIPEDAL_UI_PREFIX "fileExtension" @@ -112,6 +113,7 @@ namespace pipedal { std::vector fileTypes_; std::string patchProperty_; std::string portGroup_; + std::string resourceDirectory_; public: using ptr = std::shared_ptr; UiFileProperty() { } @@ -131,6 +133,8 @@ namespace pipedal { bool IsValidExtension(const std::string&extension) const; static bool IsDirectoryNameValid(const std::string&value); + const std::string&resourceDirectory() const { return resourceDirectory_; } + public: DECLARE_JSON_MAP(UiFileProperty); }; diff --git a/src/PluginHost.cpp b/src/PluginHost.cpp index ecdc638..3cb47a2 100644 --- a/src/PluginHost.cpp +++ b/src/PluginHost.cpp @@ -32,7 +32,7 @@ #include "JackConfiguration.hpp" #include "lv2/urid/urid.h" #include "lv2/ui/ui.h" -//#include "lv2.h" +// #include "lv2.h" #include "lv2/atom/atom.h" #include "lv2/time/time.h" #include "lv2/state/state.h" @@ -127,7 +127,7 @@ void PluginHost::LilvUris::Initialize(LilvWorld *pWorld) atom__bufferType = lilv_new_uri(pWorld, LV2_ATOM__bufferType); atom__Path = lilv_new_uri(pWorld, LV2_ATOM__Path); presets__preset = lilv_new_uri(pWorld, LV2_PRESETS__Preset); - state__state = lilv_new_uri(pWorld,LV2_STATE__state); + state__state = lilv_new_uri(pWorld, LV2_STATE__state); rdfs__label = lilv_new_uri(pWorld, LILV_NS_RDFS "label"); lv2core__symbol = lilv_new_uri(pWorld, LV2_CORE__symbol); @@ -140,6 +140,7 @@ void PluginHost::LilvUris::Initialize(LilvWorld *pWorld) pipedalUI__patchProperty = lilv_new_uri(pWorld, PIPEDAL_UI__patchProperty); pipedalUI__directory = lilv_new_uri(pWorld, PIPEDAL_UI__directory); pipedalUI__fileTypes = lilv_new_uri(pWorld, PIPEDAL_UI__fileTypes); + pipedalUI__resourceDirectory = lilv_new_uri(pWorld, PIPEDAL_UI__resourceDirectory); pipedalUI__fileProperty = lilv_new_uri(pWorld, PIPEDAL_UI__fileProperty); pipedalUI__fileExtension = lilv_new_uri(pWorld, PIPEDAL_UI__fileExtension); pipedalUI__mimeType = lilv_new_uri(pWorld, PIPEDAL_UI__mimeType); @@ -163,9 +164,9 @@ void PluginHost::LilvUris::Initialize(LilvWorld *pWorld) lv2__symbol = lilv_new_uri(pWorld, LV2_CORE__symbol); lv2__port = lilv_new_uri(pWorld, LV2_CORE__port); - #define MOD_PREFIX "http://moddevices.com/ns/mod#" - mod__label = lilv_new_uri(pWorld,MOD_PREFIX "label"); - mod__brand = lilv_new_uri(pWorld,MOD_PREFIX "brand"); +#define MOD_PREFIX "http://moddevices.com/ns/mod#" + mod__label = lilv_new_uri(pWorld, MOD_PREFIX "label"); + mod__brand = lilv_new_uri(pWorld, MOD_PREFIX "brand"); // ui:portNotification // [ // ui:portIndex 3; @@ -660,16 +661,15 @@ Lv2PluginInfo::Lv2PluginInfo(PluginHost *lv2Host, LilvWorld *pWorld, const LilvP AutoLilvNode name = (lilv_plugin_get_name(pPlugin)); this->name_ = nodeAsString(name); - AutoLilvNode brand = lilv_world_get(pWorld,plugUri,lv2Host->lilvUris->mod__brand, nullptr); + AutoLilvNode brand = lilv_world_get(pWorld, plugUri, lv2Host->lilvUris->mod__brand, nullptr); this->brand_ = nodeAsString(brand); - AutoLilvNode label = lilv_world_get(pWorld,plugUri,lv2Host->lilvUris->mod__label, nullptr); + AutoLilvNode label = lilv_world_get(pWorld, plugUri, lv2Host->lilvUris->mod__label, nullptr); this->label_ = nodeAsString(label); if (label_.length() == 0) { this->label_ = this->name_; - } - + } AutoLilvNode author_name = (lilv_plugin_get_author_name(pPlugin)); this->author_name_ = nodeAsString(author_name); @@ -1242,14 +1242,16 @@ void PluginHost::PortValueCallback(const char *symbol, void *user_data, const vo if (type == pHost->urids->atom__Double) { (*pState->values)[symbol] = (float)(*static_cast(value)); - } else if (type == pHost->urids->atom__Float) + } + else if (type == pHost->urids->atom__Float) { (*pState->values)[symbol] = *static_cast(value); } else if (type == pHost->urids->atom_Int) { (*pState->values)[symbol] = *static_cast(value); - } else + } + else { pState->failed = true; } @@ -1289,7 +1291,6 @@ PluginPresets PluginHost::GetFactoryPluginPresets(const std::string &pluginUri) PresetCallbackState cbData{this, &controlValues, false}; lilv_state_emit_port_values(state, PortValueCallback, (void *)&cbData); - int numProperties = lilv_state_get_num_properties(state); // can't handle std:state part of preset. if (numProperties == 0) @@ -1298,11 +1299,12 @@ PluginPresets PluginHost::GetFactoryPluginPresets(const std::string &pluginUri) { result.presets_.push_back(PluginPreset(result.nextInstanceId_++, strLabel, controlValues, Lv2PluginState())); } - } else { + } + else + { result.presets_.push_back(PluginPreset::MakeLilvPreset(result.nextInstanceId_++, strLabel, controlValues, lilv_node_as_uri(preset))); } lilv_state_free(state); - } } } @@ -1353,7 +1355,7 @@ Lv2PortGroup::Lv2PortGroup(PluginHost *lv2Host, const std::string &groupUri) name_ = nodeAsString(nameNode); } -bool Lv2PluginInfo::isSplit() const +bool Lv2PluginInfo::isSplit() const { return uri_ == SPLIT_PEDALBOARD_ITEM_URI; } @@ -1362,6 +1364,112 @@ std::shared_ptr PluginHost::GetHostWorkerThread() return pHostWorkerThread; } +class ResourceInfo { +public: + ResourceInfo(const std::string&filePropertyDirectory,const std::string&resourceDirectory) + :filePropertyDirectory(filePropertyDirectory), resourceDirectory(resourceDirectory) {} + + std::string filePropertyDirectory; + std::string resourceDirectory; + bool operator==(const ResourceInfo&other) const { + return this->filePropertyDirectory == other.filePropertyDirectory && this->resourceDirectory == other.resourceDirectory; + + } + bool operator<(const ResourceInfo&other) const { + if (this->filePropertyDirectory < other.filePropertyDirectory) + return true; + if (this->filePropertyDirectory > other.filePropertyDirectory) + return false; + return this->resourceDirectory < other.resourceDirectory; + + } +}; + +static bool anyTargetFilesExist(const std::filesystem::path &sourceDirectory, const std::filesystem::path&targetDirectory) +{ + namespace fs = std::filesystem; + try { + if (!fs::exists(targetDirectory)) return false; + for (auto&directoryEntry : fs::directory_iterator(sourceDirectory)) + { + fs::path thisPath = directoryEntry.path(); + if (directoryEntry.is_directory()) + { + auto name = thisPath.filename(); + fs::path childSource = sourceDirectory / name; + fs::path childTarget = targetDirectory / name; + if (anyTargetFilesExist(childSource,childTarget)) + { + return true; + } + } else { + fs::path targetPath = targetDirectory / thisPath.filename(); + if (fs::exists(targetPath)) + { + return true; + } + } + } + + } catch (const std::exception&) { + + } + return false; +} + +static void createTargetLinks(const std::filesystem::path &sourceDirectory, const std::filesystem::path &targetDirectory) +{ + namespace fs = std::filesystem; + + fs::create_directories(targetDirectory); + + for (auto &dirEntry : fs::directory_iterator(sourceDirectory)) + { + fs::path childSource = dirEntry.path(); + fs::path childTarget = targetDirectory / childSource.filename(); + if (dirEntry.is_directory()) { + createTargetLinks(childSource,childTarget); + } else { + fs::create_symlink(childSource,childTarget); + } + } +} +void PluginHost::CheckForResourceInitialization(const std::string &pluginUri,const std::filesystem::path&pluginUploadDirectory) +{ + + auto plugin = GetPluginInfo(pluginUri); + if (plugin) + { + std::filesystem::path bundlePath = plugin->bundle_path(); + if (!plugin->piPedalUI()) + return; + const auto& fileProperties = plugin->piPedalUI()->fileProperties(); + if (fileProperties.size() != 0 && !pluginsThatHaveBeenCheckedForResources.contains(pluginUri)) + { + pluginsThatHaveBeenCheckedForResources.insert(pluginUri); + + // eliminate duplicates. + std::set resourceInfoSet; + for (const auto&fileProperty: fileProperties) + { + if (!fileProperty->resourceDirectory().empty() && !fileProperty->directory().empty()) + { + resourceInfoSet.insert(ResourceInfo(fileProperty->directory(),fileProperty->resourceDirectory())); + } + } + for (const ResourceInfo&resourceInfo: resourceInfoSet) + { + std::filesystem::path sourcePath = bundlePath / resourceInfo.resourceDirectory; + std::filesystem::path targetPath = pluginUploadDirectory / resourceInfo.filePropertyDirectory; + if (!anyTargetFilesExist(sourcePath,targetPath)) + { + createTargetLinks(sourcePath,targetPath); + } + + } + } + } +} // void PiPedalHostLogError(const std::string &error) // { // Lv2Log::error("%s",error.c_str()); diff --git a/src/PluginHost.hpp b/src/PluginHost.hpp index 29b52e3..0ba6c3a 100644 --- a/src/PluginHost.hpp +++ b/src/PluginHost.hpp @@ -30,6 +30,7 @@ #include #include #include "IHost.hpp" +#include //#include "lv2.h" #include "Units.hpp" @@ -685,7 +686,7 @@ namespace pipedal AutoLilvNode pipedalUI__patchProperty; AutoLilvNode pipedalUI__fileProperty; - + AutoLilvNode pipedalUI__resourceDirectory; AutoLilvNode pipedalUI__fileTypes; AutoLilvNode pipedalUI__fileExtension; AutoLilvNode pipedalUI__mimeType; @@ -811,8 +812,14 @@ namespace pipedal public: virtual MapFeature &GetMapFeature() { return this->mapFeature; } + void CheckForResourceInitialization(const std::string& pluginUri,const std::filesystem::path& pluginUploadDirectory); private: + + std::set pluginsThatHaveBeenCheckedForResources; + + void CheckForResourceInitization(const std::string pluginUri); + virtual LV2_URID_Map *GetLv2UridMap() { return this->mapFeature.GetMap(); diff --git a/src/Storage.cpp b/src/Storage.cpp index 87cd435..24079a6 100644 --- a/src/Storage.cpp +++ b/src/Storage.cpp @@ -1,4 +1,4 @@ -// Copyright (c) 2022-2023 Robin Davies +// Copyright (c) 2022-2024 Robin Davies // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in @@ -40,6 +40,7 @@ const char *BANKS_FILENAME = "index.banks"; #define USER_SETTINGS_FILENAME "userSettings.json"; Storage::Storage() +: locale("en_US.UTF-8") { SetConfigRoot("~/var/Config"); SetDataRoot("~/var/PiPedal"); @@ -265,7 +266,7 @@ std::filesystem::path Storage::GetPluginPresetsDirectory() const { return this->dataRoot / "plugin_presets"; } -std::filesystem::path Storage::GetPluginAudioFileDirectory() const +std::filesystem::path Storage::GetPluginUploadDirectory() const { return this->dataRoot / "audio_uploads"; } @@ -1467,11 +1468,6 @@ std::vector Storage::GetSystemMidiBindings() return result; } -static bool containsDotDot(const std::string &value) -{ - std::size_t offset = value.find(".."); - return offset != std::string::npos; -} static bool containsDirectorySeparator(const std::string &value) { if (value.find("/") != std::string::npos) @@ -1483,19 +1479,12 @@ static bool containsDirectorySeparator(const std::string &value) return false; } + static void ThrowPermissionDeniedError() { throw std::logic_error("Permission denied."); } -class LexicographicCompare -{ -public: - bool operator()(const std::string &left, const std::string &right) - { - return std::lexicographical_compare(left.begin(), left.end(), right.begin(), right.end()); - } -} lexicographicCompare; std::vector Storage::GetFileList(const UiFileProperty &fileProperty) { @@ -1509,7 +1498,7 @@ std::vector Storage::GetFileList(const UiFileProperty &fileProperty // if fileProperty has a user-accessible directory, push the entire file path. if (fileProperty.directory().size() != 0) { - std::filesystem::path audioFileDirectory = this->GetPluginAudioFileDirectory() / fileProperty.directory(); + std::filesystem::path audioFileDirectory = this->GetPluginUploadDirectory() / fileProperty.directory(); try { for (auto const &dir_entry : std::filesystem::directory_iterator(audioFileDirectory)) @@ -1537,17 +1526,110 @@ std::vector Storage::GetFileList(const UiFileProperty &fileProperty // sort lexicographically - std::sort(result.begin(), result.end(), lexicographicCompare); + std::sort(result.begin(), result.end(), [this](const std::string&left,const std::string&right) { + return this->locale(left,right) < 0; + }); return result; } -bool Storage::IsValidSampleFile(const std::filesystem::path &fileName) +static bool ensureNoDotDot(const std::filesystem::path&path) +{ + for (auto segment_: path) + { + std::string segment = segment_.string(); + if (segment.starts_with(".")) + { + // the linux rule: any path that consists of all '.'s. + bool valid = false; + for (auto c: segment) + { + if (c != '.') + { + valid = true; + break; + } + } + if (!valid) + { + return false; + } + + } + } + return true; +} + + +std::vector Storage::GetFileList2(const std::string &relativePath,const UiFileProperty &fileProperty) +{ + if (!ensureNoDotDot(relativePath)) + { + ThrowPermissionDeniedError(); + } + if (!UiFileProperty::IsDirectoryNameValid(fileProperty.directory())) + { + ThrowPermissionDeniedError(); + } + + std::vector result; + + // if fileProperty has a user-accessible directory, push the entire file path. + if (fileProperty.directory().size() != 0) + { + std::filesystem::path audioFileDirectory = this->GetPluginUploadDirectory() / fileProperty.directory() / relativePath; + try + { + for (auto const &dir_entry : std::filesystem::directory_iterator(audioFileDirectory)) + { + if (dir_entry.is_regular_file()) + { + auto &path = dir_entry.path(); + auto name = path.filename().string(); + if (name.length() > 0 && name[0] != '.') // don't show hidden files. + { + if (fileProperty.IsValidExtension(path.extension().string())) + { + // a relative path! + result.push_back(FileEntry(path,false)); + } + } + } else if (dir_entry.is_directory()) { + result.push_back(FileEntry(dir_entry.path(),true)); + } + } + } + catch (const std::exception &error) + { + throw std::logic_error("GetFileList failed. Directory not found: " + audioFileDirectory.string()); + } + } + + // sort lexicographically + + std::sort(result.begin(), result.end(),[this](const FileEntry&l, const FileEntry&r) { + if (l.isDirectory_ != r.isDirectory_) + { + return l.isDirectory_ > r.isDirectory_; + } + return this->locale(l.filename_,r.filename_); + + }); + return result; +} + + +bool Storage::IsValidSampleFileName(const std::filesystem::path &fileName) { if (!fileName.is_absolute()) { return false; } - std::filesystem::path audioFilePath = this->GetPluginAudioFileDirectory(); + if (!ensureNoDotDot(fileName)) + { + return false; + } + + std::filesystem::path audioFilePath = this->GetPluginUploadDirectory(); std::filesystem::path parentDirectory = fileName.parent_path(); while (true) @@ -1557,9 +1639,6 @@ bool Storage::IsValidSampleFile(const std::filesystem::path &fileName) return false; } std::string name = parentDirectory.filename().string(); - if (name == ".." || name == ".") - return false; - if (parentDirectory == audioFilePath) return true; parentDirectory = parentDirectory.parent_path(); @@ -1571,7 +1650,7 @@ bool Storage::IsValidSampleFile(const std::filesystem::path &fileName) } void Storage::DeleteSampleFile(const std::filesystem::path &fileName) { - if (!IsValidSampleFile(fileName)) + if (!IsValidSampleFileName(fileName)) { throw std::logic_error("Permission denied."); } @@ -1581,7 +1660,16 @@ void Storage::DeleteSampleFile(const std::filesystem::path &fileName) } try { - std::filesystem::remove(fileName); + if (std::filesystem::is_directory(fileName)) + { + if (fileName.string().length() > 1) // guard against rm -rf / (bitter experience) + { + std::filesystem::remove_all(fileName); + } + } + else { + std::filesystem::remove(fileName); + } } catch (const std::exception &) { @@ -1590,24 +1678,20 @@ void Storage::DeleteSampleFile(const std::filesystem::path &fileName) } std::filesystem::path Storage::MakeUserFilePath(const std::string &directory, const std::string &filename) { - if (!UiFileProperty::IsDirectoryNameValid(directory)) + if (!ensureNoDotDot(directory)) { - throw std::logic_error("Permission denied."); + throw std::logic_error(SS("Invalide filename: " << filename)); } std::filesystem::path filePath{filename}; - if (filePath.has_parent_path()) + std::filesystem::path result = this->GetPluginUploadDirectory() / directory / filename; + if (!this->IsValidSampleFileName(result)) { - throw std::logic_error("Permission denied."); - } - std::filesystem::path result = this->GetPluginAudioFileDirectory() / directory / filename; - if (!this->IsValidSampleFile(result)) - { - throw std::logic_error("Permission denied."); + throw std::logic_error(SS("Invalid upload path: " << result)); } return result; } -std::string Storage::UploadUserFile(const std::string &directory, const std::string &patchProperty, const std::string &filename, const std::string &fileBody) +std::string Storage::UploadUserFile(const std::string &directory, const std::string &patchProperty, const std::string &filename, std::istream&stream, size_t contentLength) { std::filesystem::path path; if (directory.length() != 0) @@ -1632,15 +1716,128 @@ std::string Storage::UploadUserFile(const std::string &directory, const std::str { throw std::logic_error(SS("Can't create file " << path << ".")); } - f.write(fileBody.c_str(), fileBody.length()); + std::vector buffer; + size_t BUFFER_SIZE = 64*1024; + buffer.resize(BUFFER_SIZE); + char *pBuffer= (char*)&(buffer[0]); + while (contentLength != 0) + { + size_t thisTime = std::min(BUFFER_SIZE,contentLength); + stream.read(pBuffer,(std::streamsize)thisTime); + if (!stream) { + throw std::runtime_error("Unable to read body input stream."); + } + f.write(pBuffer,(std::streamsize)thisTime); + if (!f) { + throw std::runtime_error("Failed to write to upload file."); + } + contentLength -= thisTime; + } } catch (const std::exception &e) { Lv2Log::error(SS("Upload failed. " << e.what())); + std::filesystem::remove(path); + throw; } } return path.string(); } +std::string Storage::CreateNewSampleDirectory(const std::string&relativePath, const UiFileProperty&uiFileProperty) +{ + if (uiFileProperty.directory().empty()) + { + throw std::runtime_error("Invalid UI File Property."); + } + std::filesystem::path path = this->GetPluginUploadDirectory() / uiFileProperty.directory() / relativePath; + if (!this->IsValidSampleFileName(path)) + { + throw std::runtime_error("Invalid file name."); + } + if (std::filesystem::exists(path)) + { + throw std::runtime_error("A directory with that name already exists."); + } + std::filesystem::create_directories(path); + return path; + +} +std::string Storage::RenameFilePropertyFile( + const std::string&oldRelativePath, + const std::string&newRelativePath, + const UiFileProperty&uiFileProperty) +{ + if (uiFileProperty.directory().empty()) + { + throw std::runtime_error("Invalid UI File Property."); + } + std::filesystem::path oldPath = this->GetPluginUploadDirectory() / uiFileProperty.directory() / oldRelativePath; + if (!this->IsValidSampleFileName(oldPath)) + { + throw std::runtime_error("Invalid file name."); + } + if (!std::filesystem::exists(oldPath)) + { + throw std::runtime_error("Original path does not exist."); + } + + std::filesystem::path newPath = this->GetPluginUploadDirectory() / uiFileProperty.directory() / newRelativePath; + if (!this->IsValidSampleFileName(newPath)) + { + throw std::runtime_error("Invalid file name."); + } + if (std::filesystem::exists(newPath)) + { + if (std::filesystem::is_directory(newPath)) + { + throw std::runtime_error("A directory with that name already exists."); + } else { + throw std::runtime_error("A file with that name already exists."); + } + } + + std::filesystem::rename(oldPath,newPath); + return newPath; + +} + +void Storage::FillSampleDirectoryTree(FilePropertyDirectoryTree*node, const std::filesystem::path&directory) const +{ + for (auto child: std::filesystem::directory_iterator(directory)) + { + if (child.is_directory()) + { + const auto& childPath = child.path(); + FilePropertyDirectoryTree::ptr childTree = std::make_unique(childPath.filename()); + FillSampleDirectoryTree(childTree.get(),childPath); + node->children_.push_back(std::move(childTree)); + } + } + std::sort(node->children_.begin(),node->children_.end(), + [this](const FilePropertyDirectoryTree::ptr&left,const FilePropertyDirectoryTree::ptr&right) + { + return this->locale(left->directoryName_,right->directoryName_); + }); +} +FilePropertyDirectoryTree::ptr Storage::GetFilePropertydirectoryTree(const UiFileProperty&uiFileProperty) const +{ + FilePropertyDirectoryTree::ptr result = std::make_unique(""); + if (uiFileProperty.directory().empty()) + { + throw std::runtime_error("Invalid uiFileProperty"); + } + if (!ensureNoDotDot(uiFileProperty.directory())) + { + throw std::runtime_error("Invalid uiFileProperty"); + } + std::filesystem::path rootDirectory = this->GetPluginUploadDirectory() / uiFileProperty.directory(); + + FillSampleDirectoryTree(result.get(),rootDirectory); + + return result; +} + + const PluginPresetIndex &Storage::GetPluginPresetIndex() { return pluginPresetIndex; diff --git a/src/Storage.hpp b/src/Storage.hpp index 4cbee39..deaea9c 100644 --- a/src/Storage.hpp +++ b/src/Storage.hpp @@ -28,7 +28,10 @@ #include "JackServerSettings.hpp" #include "WifiConfigSettings.hpp" #include "WifiDirectConfigSettings.hpp" +#include "FileEntry.hpp" #include +#include +#include "FilePropertyDirectoryTree.hpp" namespace pipedal { @@ -64,6 +67,7 @@ struct PluginPresetValues { class Storage { private: + std::locale locale; std::filesystem::path dataRoot; std::filesystem::path configRoot; BankIndex bankIndex; @@ -71,6 +75,7 @@ private: PluginPresetIndex pluginPresetIndex; private: + void FillSampleDirectoryTree(FilePropertyDirectoryTree*node, const std::filesystem::path&directory) const; void MaybeCopyDefaultPresets(); static std::string SafeEncodeName(const std::string& name); @@ -107,9 +112,9 @@ public: void SetDataRoot(const std::filesystem::path& path); void SetConfigRoot(const std::filesystem::path& path); - std::filesystem::path GetPluginAudioFileDirectory() const; + std::filesystem::path GetPluginUploadDirectory() const; - std::vector GetPedalboards(); + //std::vector GetPedalboards(); const BankIndex & GetBanks() const { return bankIndex; } @@ -149,6 +154,7 @@ public: int64_t DeleteBank(int64_t bankId); std::vector GetFileList(const UiFileProperty&fileProperty); + std::vector GetFileList2(const std::string&relativePath,const UiFileProperty&fileProperty); void SetJackChannelSelection(const JackChannelSelection&channelSelection); @@ -176,7 +182,7 @@ private: void SavePluginPresetIndex(); std::filesystem::path GetPluginPresetPath(const std::string &pluginUri) const; - bool IsValidSampleFile(const std::filesystem::path&fileName); + bool IsValidSampleFileName(const std::filesystem::path&fileName); std::filesystem::path MakeUserFilePath(const std::string &directory, const std::string&filename); public: @@ -212,7 +218,13 @@ public: void SetSystemMidiBindings(const std::vector&bindings); std::vector GetSystemMidiBindings(); void DeleteSampleFile(const std::filesystem::path &fileName); - std::string UploadUserFile(const std::string &directory, const std::string &patchProperty,const std::string&filename,const std::string&fileBody); + std::string UploadUserFile(const std::string &directory, const std::string &patchProperty,const std::string&filename,std::istream&stream, size_t contentLength); + std::string CreateNewSampleDirectory(const std::string&relativePath, const UiFileProperty&uiFileProperty); + std::string RenameFilePropertyFile( + const std::string&oldRelativePath, + const std::string&newRelativePath, + const UiFileProperty&uiFileProperty); + FilePropertyDirectoryTree::ptr GetFilePropertydirectoryTree(const UiFileProperty&uiFileProperty) const; }; diff --git a/src/TemporaryFile.cpp b/src/TemporaryFile.cpp new file mode 100644 index 0000000..bababdd --- /dev/null +++ b/src/TemporaryFile.cpp @@ -0,0 +1,61 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#include "TemporaryFile.hpp" + +using namespace pipedal; + + +TemporaryFile::TemporaryFile(const std::filesystem::path&directory) +{ + namespace fs = std::filesystem; + fs::create_directories(directory); + + // Generate a unique filename + std::string filename; + do { + std::string random_string(8, '\0'); + const char alphanum[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + + srand(static_cast(time(nullptr))); + for (int i = 0; i < 8; ++i) { + random_string[i] = alphanum[rand() % (sizeof(alphanum) - 1)]; + } + + filename = directory / ("temp_" + random_string + ".tmp"); + } while (fs::exists(filename)); + + // Create the file + std::ofstream file(filename); + if (!file) { + throw std::runtime_error("Failed to create temporary file"); + } + file.close(); + + this->path = filename; + +} +TemporaryFile::~TemporaryFile() +{ + if (!path.empty()) + { + std::filesystem::remove(path); + } +} + diff --git a/src/TemporaryFile.hpp b/src/TemporaryFile.hpp new file mode 100644 index 0000000..dd0e1a5 --- /dev/null +++ b/src/TemporaryFile.hpp @@ -0,0 +1,34 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#pragma once +#include +namespace pipedal { + class TemporaryFile { + public: + TemporaryFile(const TemporaryFile&) = delete; + TemporaryFile&operator=(const TemporaryFile&) = delete; + + TemporaryFile(const std::filesystem::path&parentDirectory); + ~TemporaryFile(); + const std::filesystem::path&Path()const { return path;} + private: + std::filesystem::path path; + }; +} \ No newline at end of file diff --git a/src/WebServer.cpp b/src/WebServer.cpp index ba86964..781d726 100644 --- a/src/WebServer.cpp +++ b/src/WebServer.cpp @@ -1,3 +1,21 @@ +// Copyright (c) 2022-2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. #include "pch.h" @@ -28,29 +46,484 @@ #include #include "WebServerLog.hpp" +#include "TemporaryFile.hpp" using namespace pipedal; using namespace std; const bool ENABLE_KEEP_ALIVE = true; +const std::filesystem::path WEB_TEMP_DIR{"/var/pipedal/web_temp"}; using tcp = boost::asio::ip::tcp; // from -const size_t MAX_READ_SIZE = 1 * 1024 * 204; +const size_t MAX_READ_SIZE = 512 * 1024 * 1024; +; using namespace boost; -class CustomPpConfig: public websocketpp::config::asio { +class request_with_file_upload : public websocketpp::http::parser::parser +{ +public: + using super = websocketpp::http::parser::parser; + + typedef request_with_file_upload type; + typedef std::shared_ptr ptr; + + request_with_file_upload() + : m_buf(std::make_shared()), m_ready(false) {} + + size_t consume(char const *buf, size_t len, std::error_code &ec); + + /// Returns whether or not the request is ready for reading. + bool ready() const + { + return m_ready; + } + std::istream &get_body_input_stream(); + + const std::filesystem::path &get_body_input_file(); + size_t content_length() const { return m_content_length; } + + /// Returns the full raw request (including the body) + std::string raw() const; + + /// Returns the raw request headers only (similar to an HTTP HEAD request) + std::string raw_head() const; + + /// Set the HTTP method. + /** + * Must be a valid HTTP token + * + * @since 0.9.0 added return value and removed exception + * + * @param [in] method The value to set the method to. + * @return A status code describing the outcome of the operation. + */ + std::error_code set_method(std::string const &method); + + /// Return the request method + std::string const &get_method() const + { + return m_method; + } + + /// Set the HTTP uri. + /** + * Must be a valid HTTP uri + * + * @since 0.9.0 Return value added + * + * @param uri The URI to set + * @return A status code describing the outcome of the operation. + */ + std::error_code set_uri(std::string const &uri); + + /// Return the requested URI + std::string const &get_uri() const + { + return m_uri; + } + /// Helper function for message::consume. Process request line + /** + * @since 0.9.0 (ec parameter added, exceptions removed) + * + * @param [in] begin An iterator to the beginning of the sequence. + * @param [in] end An iterator to the end of the sequence. + * @return A status code describing the outcome of the operation. + */ + + bool prepare_body(std::error_code &ec); + + std::error_code process(std::string::iterator begin, std::string::iterator end); + size_t process_body(char const *buf, size_t len, + std::error_code &ec); + std::shared_ptr m_buf; + std::string m_method; + std::string m_uri; + size_t m_content_length; + bool m_ready; + bool m_uploading_to_file = false; + size_t m_max_in_memory_upload = 0; // saver to have one code path. And any input body is going to be an upload anyway. + std::shared_ptr m_temporaryFile; + std::ofstream m_outputStream; + std::ifstream m_inputStream; + std::stringstream m_stringInputStream; + bool m_outputOpen = false; +}; +const std::filesystem::path &request_with_file_upload::get_body_input_file() +{ + if (!this->m_temporaryFile) + throw std::runtime_error("Request does not have a body."); + return this->m_temporaryFile->Path(); +} +std::istream &request_with_file_upload::get_body_input_stream() +{ + if (!m_outputOpen) + { + m_outputOpen = true; + if (m_uploading_to_file) + { + m_inputStream.open(this->m_temporaryFile->Path(), std::ios_base::in | std::ios_base::binary); + return m_inputStream; + } + else + { + auto body = super::get_body(); + m_stringInputStream.write(body.c_str(), body.length()); + m_stringInputStream.flush(); + m_stringInputStream.seekg(0); + return m_stringInputStream; + } + } + else + { + if (m_uploading_to_file) + { + return m_inputStream; + } + else + { + return m_stringInputStream; + } + } +} +bool request_with_file_upload::prepare_body(std::error_code &ec) +{ + using namespace websocketpp::http; + bool result = super::prepare_body(ec); + m_content_length = 0; + if (!result) + return result; + m_content_length = m_body_bytes_needed; + if (m_body_bytes_needed > m_max_in_memory_upload) + { + m_uploading_to_file = true; + try + { + this->m_temporaryFile = std::make_shared(WEB_TEMP_DIR); + m_outputStream.open(this->m_temporaryFile->Path(), std::ios_base::trunc | std::ios_base::out | std::ios_base::binary); + if (!m_outputStream) + { + throw std::runtime_error(SS("Unable to open file " << this->m_temporaryFile->Path())); + } + } + catch (const std::exception &e) + { + Lv2Log::error(SS("Unabe to create upload file. " << e.what())); + ec = error::make_error_code(error::istream_bad); + } + } + return true; +} + +inline size_t request_with_file_upload::process_body(char const *buf, size_t len, + std::error_code &ec) +{ + using namespace websocketpp::http; + using namespace websocketpp::http::parser; + + if (!this->m_uploading_to_file) + { + return super::process_body(buf, len, ec); + } + if (m_body_encoding == body_encoding::plain) + { + size_t processed = (std::min)(m_body_bytes_needed, len); + try + { + m_outputStream.write(buf, processed); + } + catch (const std::exception &e) + { + Lv2Log::error(SS("Can't write to web temporary file " << m_temporaryFile->Path() << ". " << e.what())); + ec = error::make_error_code(error::istream_bad); + return 0; + } + m_body_bytes_needed -= processed; + ec = std::error_code(); + return processed; + } + else if (m_body_encoding == body_encoding::chunked) + { + ec = error::make_error_code(error::unsupported_transfer_encoding); + return 0; + // TODO: support for chunked transfers? + } + else + { + ec = error::make_error_code(error::unknown_transfer_encoding); + return 0; + } +} + +inline size_t request_with_file_upload::consume(char const *buf, size_t len, std::error_code &ec) +{ + using namespace websocketpp::http; + + size_t bytes_processed = 0; + + if (m_ready) + { + // the request is already complete. End immediately without reading. + ec = std::error_code(); + return 0; + } + + if (m_body_bytes_needed > 0) + { + // The headers are complete, but we are still expecting more body + // bytes. Process body bytes. + bytes_processed = process_body(buf, len, ec); + if (ec) + { + return bytes_processed; + } + + // if we have ready all the expected body bytes set the ready flag + if (body_ready()) + { + m_outputStream.close(); + m_ready = true; + } + return bytes_processed; + } + + // at this point we have an incomplete request still waiting for headers + + // copy new candidate bytes into our local buffer. This buffer may have + // leftover bytes from previous calls. Not all of these bytes are + // necessarily header bytes (they might be body or even data after this + // request entirely for a keepalive request) + m_buf->append(buf, len); + + // Search for delimiter in buf. If found read until then. If not read all + std::string::iterator begin = m_buf->begin(); + std::string::iterator end; + + for (;;) + { + // search for line delimiter in our local buffer + end = std::search( + begin, + m_buf->end(), + header_delimiter, + header_delimiter + sizeof(header_delimiter) - 1); + + if (end == m_buf->end()) + { + // we didn't find the delimiter + + // check that the confirmed header bytes plus the outstanding + // candidate bytes do not put us over the header size limit. + if (m_header_bytes + (end - begin) > max_header_size) + { + ec = error::make_error_code(error::request_header_fields_too_large); + return 0; + } + + // We are out of bytes but not over any limits yet. Discard the + // processed bytes and copy the remaining unprecessed bytes to the + // beginning of the buffer in prep for another call to consume. + + // If there are no processed bytes in the buffer right now don't + // copy the unprocessed ones over themselves. + if (begin != m_buf->begin()) + { + std::copy(begin, end, m_buf->begin()); + m_buf->resize(static_cast(end - begin)); + } + + ec = std::error_code(); + return len; + } + + // at this point we have found a delimiter and the range [begin,end) + // represents a line to be processed + + // update count of header bytes read so far + m_header_bytes += (end - begin + sizeof(header_delimiter)); + + if (m_header_bytes > max_header_size) + { + // This read exceeded max header size + ec = error::make_error_code(error::request_header_fields_too_large); + return 0; + } + + if (end - begin == 0) + { + // we got a blank line, which indicates the end of the headers + + // If we never got a valid method or are missing a host header then + // this request is invalid. + if (m_method.empty() || get_header("Host").empty()) + { + ec = error::make_error_code(error::incomplete_request); + return 0; + } + + // any bytes left over in the local buffer are bytes we didn't use. + // When we report how many bytes we consumed we need to subtract + // these so the caller knows that they need to be processed by some + // other logic. + bytes_processed = (len - static_cast(m_buf->end() - end) + sizeof(header_delimiter) - 1); + + // frees memory used temporarily during request parsing + m_buf.reset(); + + // if this was not an upgrade request and has a content length + // continue capturing content-length bytes and expose them as a + // request body. + + bool need_more = prepare_body(ec); + if (ec) + { + return 0; + } + + if (need_more) + { + bytes_processed += process_body(buf + bytes_processed, len - bytes_processed, ec); + if (ec) + { + return 0; + } + if (body_ready()) + { + m_ready = true; + } + ec = std::error_code(); + return bytes_processed; + } + else + { + m_ready = true; + + // return number of bytes processed (starting bytes - bytes left) + ec = std::error_code(); + return bytes_processed; + } + } + else + { + // we got a line with content + if (m_method.empty()) + { + // if we haven't found a method yet process this line as a first line + ec = this->process(begin, end); + } + else + { + // this is a second (or later) line, process as a header + ec = this->process_header(begin, end); + } + if (ec) + { + return 0; + } + } + + // if we got here it means there is another header line to read. + // advance our cursor to the first character after the most recent + // delimiter found. + begin = end + (sizeof(header_delimiter) - 1); + } +} + +inline std::string request_with_file_upload::raw() const +{ + // TODO: validation. Make sure all required fields have been set? + std::stringstream ret; + + ret << m_method << " " << m_uri << " " << get_version() << "\r\n"; + ret << raw_headers() << "\r\n" + << m_body; + + return ret.str(); +} + +inline std::string request_with_file_upload::raw_head() const +{ + // TODO: validation. Make sure all required fields have been set? + std::stringstream ret; + + ret << m_method << " " << m_uri << " " << get_version() << "\r\n"; + ret << raw_headers() << "\r\n"; + + return ret.str(); +} + +inline std::error_code request_with_file_upload::set_method(std::string const &method) +{ + using namespace websocketpp::http; + + if (std::find_if(method.begin(), method.end(), is_not_token_char) != method.end()) + { + return error::make_error_code(error::invalid_format); + } + + m_method = method; + return std::error_code(); +} + +inline std::error_code request_with_file_upload::set_uri(std::string const &uri) +{ + // TODO: validation? + m_uri = uri; + + return std::error_code(); +} + +inline std::error_code request_with_file_upload::process(std::string::iterator begin, std::string::iterator + end) +{ + using namespace websocketpp::http; + std::error_code ec; + + std::string::iterator cursor_start = begin; + std::string::iterator cursor_end = std::find(begin, end, ' '); + + if (cursor_end == end) + { + return error::make_error_code(error::incomplete_request); + } + + ec = set_method(std::string(cursor_start, cursor_end)); + if (ec) + { + return ec; + } + + cursor_start = cursor_end + 1; + cursor_end = std::find(cursor_start, end, ' '); + + if (cursor_end == end) + { + return error::make_error_code(error::incomplete_request); + } + + ec = set_uri(std::string(cursor_start, cursor_end)); + if (ec) + { + return ec; + } + + return set_version(std::string(cursor_end + 1, end)); +} + +class CustomPpConfig : public websocketpp::config::asio +{ public: typedef CustomPpConfig type; typedef websocketpp::config::asio base; - - static const size_t max_http_body_size = 100000000; //websocketpp::config::asio::max_http_body_size; + static size_t max_http_body_size; // websocketpp::config::asio::max_http_body_size; typedef pipedal_elog elog_type; typedef pipedal_alog alog_type; + typedef request_with_file_upload request_type; - struct transport_config : public base::transport_config { + struct transport_config : public base::transport_config + { typedef type::concurrency_type concurrency_type; typedef type::alog_type alog_type; typedef type::elog_type elog_type; @@ -62,10 +535,9 @@ public: typedef websocketpp::transport::asio::endpoint transport_type; - }; - +size_t CustomPpConfig::max_http_body_size = MAX_READ_SIZE; std::string pipedal::last_modified(const std::filesystem::path &path) @@ -86,7 +558,7 @@ pipedal::last_modified(const std::filesystem::path &path) static std::string getHostName() { char buff[512]; - if (gethostname(buff,sizeof(buff)) == 0) + if (gethostname(buff, sizeof(buff)) == 0) { buff[511] = '\0'; return buff; @@ -98,7 +570,7 @@ static std::string getIpv4Address(const std::string interface) { int fd = -1; struct ifreq ifr; - memset(&ifr,0,sizeof(ifr)); + memset(&ifr, 0, sizeof(ifr)); fd = socket(AF_INET, SOCK_DGRAM, 0); @@ -106,15 +578,16 @@ static std::string getIpv4Address(const std::string interface) ifr.ifr_addr.sa_family = AF_INET; /* I want an IP address attached to "eth0" */ - strncpy(ifr.ifr_name, interface.c_str(), IFNAMSIZ-1); + strncpy(ifr.ifr_name, interface.c_str(), IFNAMSIZ - 1); int result = ioctl(fd, SIOCGIFADDR, &ifr); - if (result == -1) return ""; - + if (result == -1) + return ""; close(fd); char *name = inet_ntoa(((struct sockaddr_in *)&ifr.ifr_addr)->sin_addr); - if (name == nullptr) return ""; + if (name == nullptr) + return ""; return name; } static std::map extensionsToMimeType = { @@ -175,6 +648,7 @@ namespace pipedal int port = -1; std::filesystem::path rootPath; int threads = 1; + size_t maxUploadSize = 512 * 1024 * 1024; std::thread *pBgThread = nullptr; std::recursive_mutex io_mutex; @@ -187,15 +661,22 @@ namespace pipedal class HttpRequestImpl : public HttpRequest { private: - const server::connection_type::request_type &m_request; + server::connection_type::request_type &m_request; public: HttpRequestImpl(const server::connection_type::request_type &request) - : m_request(request) + : m_request(const_cast(request)) // so we can get access to get_body_input_stream { } - virtual const std::string &body() const { return m_request.get_body(); } + virtual std::istream &get_body_input_stream() override { return m_request.get_body_input_stream(); } + virtual const std::filesystem::path &get_body_temporary_file() override + { + return m_request.get_body_input_file(); + } + + virtual size_t content_length() const { return m_request.content_length(); } + virtual const std::string &method() const { return m_request.get_method(); } virtual const std::string &get(const std::string &key) const { return m_request.get_header(key); } virtual bool keepAlive() const @@ -241,8 +722,9 @@ namespace pipedal private: // IWriteCallback - virtual void close() { - webSocket->close(websocketpp::close::status::normal, ""); + virtual void close() + { + webSocket->close(websocketpp::close::status::normal, ""); webSocket = nullptr; } @@ -279,7 +761,6 @@ namespace pipedal Lv2Log::info(SS("WebSocketSession opened. (" << fromAddress << ")")); - auto pFactory = pServer->GetSocketFactory(requestUri); if (!pFactory) { @@ -289,9 +770,9 @@ namespace pipedal } else { - using websocketpp::lib::bind; - using websocketpp::lib::placeholders::_1; - using websocketpp::lib::placeholders::_2; + using std::bind; + using std::placeholders::_1; + using std::placeholders::_2; webSocket->set_message_handler(bind(&WebSocketSession::on_message, this, _1, _2)); webSocket->set_close_handler(bind(&WebSocketSession::on_close, this, _1)); @@ -323,22 +804,23 @@ namespace pipedal void on_session_closed(WebSocketSession::ptr &session, connection_hdl hConnection) { - m_sessions.erase(session); + m_sessions.erase(session); session = nullptr; // probably delete here. m_connections.erase(hConnection); } void NotFound(server::connection_type &connection, const std::string &filename) { - try { + try + { // 404 error std::stringstream ss; ss << "" - << "Error 404 (Resource not found)" - << "

Error 404

" - << "

The requested URL " << HtmlHelper::HtmlEncode(filename) << " was not found on this server.

" - << ""; + << "Error 404 (Resource not found)" + << "

Error 404

" + << "

The requested URL " << HtmlHelper::HtmlEncode(filename) << " was not found on this server.

" + << ""; std::string body = ss.str(); connection.set_body(body); @@ -346,23 +828,25 @@ namespace pipedal ssLen << body.length(); connection.replace_header(HttpField::content_length, ssLen.str()); connection.set_status(websocketpp::http::status_code::not_found); - } catch (const std::exception&) + } + catch (const std::exception &) { // ignored. Things weren't going well anyway. - } + } return; }; void ServerError(server::connection_type &connection, const std::string &error) { - try { + try + { // 404 error std::stringstream ss; ss << "" - << "Error 500 (Server error)" - << "

Error 500

" - << "

" << HtmlHelper::HtmlEncode(error) << "

" - << ""; + << "Error 500 (Server error)" + << "

Error 500

" + << "

" << HtmlHelper::HtmlEncode(error) << "

" + << ""; std::string body = ss.str(); connection.set_body(body); std::stringstream ssLen; @@ -370,7 +854,8 @@ namespace pipedal connection.replace_header(HttpField::content_length, ssLen.str()); connection.set_status(websocketpp::http::status_code::internal_server_error); - } catch (const std::exception&) + } + catch (const std::exception &) { } return; @@ -457,17 +942,18 @@ namespace pipedal HttpResponseImpl res((*con)); uri requestUri; - try { - requestUri.set(con->get_uri()->str().c_str()); - } catch (const std::exception &e) + try { - ServerError(*con, SS("Unexpected error. " << e.what())); + requestUri.set(con->get_uri()->str().c_str()); + } + catch (const std::exception &e) + { + ServerError(*con, SS("Unexpected error. " << e.what())); return; } std::string fromAddress = SS(con->get_remote_endpoint()); - if (req.method() == HttpVerb::options) { res.set(HttpField::access_control_allow_origin, origin); @@ -486,14 +972,13 @@ namespace pipedal try { - if (req.method() == HttpVerb::head) { std::error_code ec; res.set(HttpField::date, HtmlHelper::timeToHttpDate(time(nullptr))); res.set(HttpField::access_control_allow_origin, origin); - requestHandler->head_response(fromAddress,requestUri, req, res, ec); + requestHandler->head_response(fromAddress, requestUri, req, res, ec); res.keepAlive(req.keepAlive()); if (ec == std::errc::no_such_file_or_directory) { @@ -515,8 +1000,7 @@ namespace pipedal res.set(HttpField::date, HtmlHelper::timeToHttpDate(time(nullptr))); res.set(HttpField::access_control_allow_origin, origin); - - requestHandler->get_response(fromAddress,requestUri, req, res, ec); + requestHandler->get_response(fromAddress, requestUri, req, res, ec); res.keepAlive(req.keepAlive()); if (ec == std::errc::no_such_file_or_directory) @@ -540,7 +1024,7 @@ namespace pipedal res.set(HttpField::date, HtmlHelper::timeToHttpDate(time(nullptr))); res.set(HttpField::access_control_allow_origin, origin); - requestHandler->post_response(fromAddress,requestUri, req, res, ec); + requestHandler->post_response(fromAddress, requestUri, req, res, ec); if (ec == std::errc::no_such_file_or_directory) { @@ -562,7 +1046,7 @@ namespace pipedal } catch (std::exception &e) { - ServerError(*con, SS("Unexpected error. " << e.what())); + ServerError(*con, SS("Unexpected error. " << e.what())); return; } } @@ -628,13 +1112,14 @@ namespace pipedal { m_connections.insert(hdl); - - try { + try + { server::connection_ptr webSocket = m_endpoint.get_con_from_hdl(hdl); WebSocketSession::ptr socketSession = std::make_shared(this, webSocket); socketSession->Open(); m_sessions.insert(socketSession); - } catch (const std::exception&e) + } + catch (const std::exception &e) { Lv2Log::error("Failed to open session: %s", e.what()); } @@ -666,9 +1151,9 @@ namespace pipedal m_endpoint.init_asio(&ioc); // Bind the handlers we are using - using websocketpp::lib::bind; - using websocketpp::lib::placeholders::_1; - using websocketpp::lib::placeholders::_2; + using std::bind; + using std::placeholders::_1; + using std::placeholders::_2; m_endpoint.set_open_handler(bind(&WebServerImpl::on_open, this, _1)); m_endpoint.set_close_handler(bind(&WebServerImpl::on_close, this, _1)); @@ -695,7 +1180,7 @@ namespace pipedal std::stringstream ss; ss << port; // m_endpoint.listen(this->address, ss.str()); - m_endpoint.listen(tcp::v6(),(uint16_t)port); + m_endpoint.listen(tcp::v6(), (uint16_t)port); m_endpoint.start_accept(); // Start IOC service threads. @@ -703,7 +1188,7 @@ namespace pipedal v.reserve(threads - 1); for (auto i = threads - 1; i > 0; --i) v.emplace_back( - [&ioc,i] + [&ioc, i] { SetThreadName(SS("web_" << i)); ioc.run(); @@ -789,13 +1274,7 @@ namespace pipedal this->pBgThread = new std::thread(ThreadProc, this); } - WebServerImpl(const std::string &address, int port, const char *rootPath, int threads) - : address(address), - rootPath(rootPath), - port(port), - threads(threads) - { - } + WebServerImpl(const std::string &address, int port, const char *rootPath, int threads, size_t maxUploadSize); }; } // namespace pipedal @@ -811,8 +1290,21 @@ std::shared_ptr WebServerImpl::GetSocketFactory(const uri &reque } return nullptr; } - -std::shared_ptr pipedal::WebServer::create(const boost::asio::ip::address &address, int port, const char *rootPath, int threads) +WebServerImpl::WebServerImpl(const std::string &address, int port, const char *rootPath, int threads, size_t maxUploadSize) + : address(address), + rootPath(rootPath), + port(port), + threads(threads), + maxUploadSize(maxUploadSize) { - return std::shared_ptr(new WebServerImpl(address.to_string(), port, rootPath, threads)); + ::CustomPpConfig::max_http_body_size = maxUploadSize; +} + +std::shared_ptr pipedal::WebServer::create( + const boost::asio::ip::address &address, + int port, + const char *rootPath, int threads, + size_t maxUploadSize) +{ + return std::shared_ptr(new WebServerImpl(address.to_string(), port, rootPath, threads, maxUploadSize)); } diff --git a/src/WebServer.hpp b/src/WebServer.hpp index 904be24..8b86ba8 100644 --- a/src/WebServer.hpp +++ b/src/WebServer.hpp @@ -26,7 +26,10 @@ namespace pipedal { class HttpRequest { public: - virtual const std::string&body() const = 0; + //virtual const std::string&body() const = 0; + virtual std::istream &get_body_input_stream() = 0; + virtual const std::filesystem::path& get_body_temporary_file() = 0; + virtual size_t content_length() const = 0; virtual const std::string &method() const = 0; virtual const std::string&get(const std::string&key) const = 0; virtual bool keepAlive() const = 0; @@ -161,14 +164,14 @@ public: virtual void head_response( const uri&request_uri, - const HttpRequest &req, + HttpRequest &req, HttpResponse &res, std::error_code &ec) = 0; virtual void head_response( const std::string&fromAddress, const uri&request_uri, - const HttpRequest &req, + HttpRequest &req, HttpResponse &res, std::error_code &ec) { head_response(request_uri,req,res,ec); @@ -178,14 +181,14 @@ public: virtual void get_response( const uri&request_uri, - const HttpRequest &req, + HttpRequest &req, HttpResponse &res, std::error_code &ec) = 0; virtual void get_response( const std::string&fromAddress, const uri&request_uri, - const HttpRequest &req, + HttpRequest &req, HttpResponse &res, std::error_code &ec) { @@ -195,7 +198,7 @@ public: virtual void post_response( const uri&request_uri, - const HttpRequest &req, + HttpRequest &req, HttpResponse &res, std::error_code &ec) { @@ -204,7 +207,7 @@ public: virtual void post_response( const std::string&fromAddress, const uri&request_uri, - const HttpRequest &req, + HttpRequest &req, HttpResponse &res, std::error_code &ec) { @@ -233,7 +236,8 @@ public: const boost::asio::ip::address &address, int port, const char *rootPath, - int threads); + int threads, + size_t maxUploadsize); }; diff --git a/src/WebServerConfig.cpp b/src/WebServerConfig.cpp new file mode 100644 index 0000000..ed08964 --- /dev/null +++ b/src/WebServerConfig.cpp @@ -0,0 +1,571 @@ + // Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#include "WebServerConfig.hpp" +#include "WebServer.hpp" +#include +#include +#include "PiPedalConfiguration.hpp" +#include "PiPedalModel.hpp" +#include "Banks.hpp" +#include "Ipv6Helpers.hpp" +#include +#include "ZipFile.hpp" +#include "PiPedalUI.hpp" + + +#define PRESET_EXTENSION ".piPreset" +#define BANK_EXTENSION ".piBank" +#define PLUGIN_PRESETS_EXTENSION ".piPluginPresets" + + +using namespace pipedal; +using namespace boost::system; + +static std::vector split(const std::string& s, char delimiter) { + std::vector tokens; + std::string token; + std::istringstream tokenStream(s); + while (std::getline(tokenStream, token, delimiter)) { + tokens.push_back(token); + } + return tokens; +} + +class ExtensionChecker { +public: + ExtensionChecker(const std::string&extensionList) + :extensions(split(extensionList,',')) + { + } + bool IsValidExtension(const std::string&extension) + { + if (extensions.size() == 0) + return true; + for (const auto&ext: extensions) + { + if (ext == extension) + return true; + } + return false; + } +private: + std::vector extensions; +}; + +class DownloadIntercept : public RequestHandler +{ + PiPedalModel *model; + +public: + DownloadIntercept(PiPedalModel *model) + : RequestHandler("/var"), + model(model) + { + } + + virtual bool wants(const std::string &method, const uri &request_uri) const + { + if (request_uri.segment_count() != 2 || request_uri.segment(0) != "var") + { + return false; + } + std::string segment = request_uri.segment(1); + if (segment == "uploadPluginPresets") + { + return true; + } + if (segment == "downloadPluginPresets") + { + return true; + } + if (segment == "downloadPreset") + { + std::string strInstanceId = request_uri.query("id"); + if (strInstanceId != "") + return true; + } + else if (segment == "uploadPreset") + { + return true; + } + if (segment == "downloadBank") + { + std::string strInstanceId = request_uri.query("id"); + if (strInstanceId != "") + return true; + } + else if (segment == "uploadBank") + { + return true; + } else if (segment == "uploadUserFile") + { + return true; + } + return false; + } + + std::string GetContentDispositionHeader(const std::string &name, const std::string &extension) + { + std::string fileName = name.substr(0, 64) + extension; + std::stringstream s; + + s << "attachment; filename*=" << HtmlHelper::Rfc5987EncodeFileName(fileName) << "; filename=\"" << HtmlHelper::SafeFileName(fileName) << "\""; + std::string result = s.str(); + return result; + } + + void GetPluginPresets(const uri &request_uri, std::string *pName, std::string *pContent) + { + std::string pluginUri = request_uri.query("id"); + auto plugin = model->GetLv2Host().GetPluginInfo(pluginUri); + *pName = plugin->name(); + + PluginPresets pluginPresets = model->GetPluginPresets(pluginUri); + + std::stringstream s; + json_writer writer(s,false); + writer.write(pluginPresets); + *pContent = s.str(); + } + void GetPreset(const uri &request_uri, std::string *pName, std::string *pContent) + { + std::string strInstanceId = request_uri.query("id"); + int64_t instanceId = std::stol(strInstanceId); + auto pedalboard = model->GetPreset(instanceId); + + // a certain elegance to using same file format for banks and presets. + BankFile file; + file.name(pedalboard.name()); + int64_t newInstanceId = file.addPreset(pedalboard); + file.selectedPreset(newInstanceId); + + std::stringstream s; + json_writer writer(s,false); + writer.write(file); + *pContent = s.str(); + *pName = pedalboard.name(); + } + void GetBank(const uri &request_uri, std::string *pName, std::string *pContent) + { + std::string strInstanceId = request_uri.query("id"); + int64_t instanceId = std::stol(strInstanceId); + BankFile bank; + model->GetBank(instanceId, &bank); + + std::stringstream s; + json_writer writer(s, true); // do what we can to reduce the file size. + writer.write(bank); + *pContent = s.str(); + *pName = bank.name(); + } + + virtual void head_response( + const uri &request_uri, + HttpRequest &req, + HttpResponse &res, + std::error_code &ec) override + { + try + { + std::string segment = request_uri.segment(1); + if (segment == "downloadPluginPresets") + { + std::string name; + std::string content; + GetPluginPresets(request_uri, &name, &content); + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + res.setContentLength(content.length()); + res.set(HttpField::content_disposition, GetContentDispositionHeader(name, PLUGIN_PRESETS_EXTENSION)); + return; + } + if (segment == "downloadPreset") + { + std::string name; + std::string content; + GetPreset(request_uri, &name, &content); + + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + res.setContentLength(content.length()); + res.set(HttpField::content_disposition, GetContentDispositionHeader(name, PRESET_EXTENSION)); + return; + } + if (segment == "downloadBank") + { + std::string name; + std::string content; + GetBank(request_uri, &name, &content); + + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + res.set(HttpField::content_disposition, GetContentDispositionHeader(name, BANK_EXTENSION)); + res.setContentLength(content.length()); + return; + } + throw PiPedalException("Not found."); + } + catch (const std::exception &e) + { + if (strcmp(e.what(), "Not found") == 0) + { + ec = boost::system::errc::make_error_code(boost::system::errc::no_such_file_or_directory); + } + else + { + ec = boost::system::errc::make_error_code(boost::system::errc::invalid_argument); + } + } + } + + virtual void get_response( + const uri &request_uri, + HttpRequest &req, + HttpResponse &res, + std::error_code &ec) override + { + try + { + std::string segment = request_uri.segment(1); + + if (segment == "downloadPluginPresets") + { + std::string name; + std::string content; + GetPluginPresets(request_uri, &name, &content); + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + res.setContentLength(content.length()); + res.set(HttpField::content_disposition, GetContentDispositionHeader(name, PLUGIN_PRESETS_EXTENSION)); + res.setBody(content); + } + else if (segment == "downloadPreset") + { + std::string name; + std::string content; + GetPreset(request_uri, &name, &content); + + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + res.setContentLength(content.length()); + res.set(HttpField::content_disposition, GetContentDispositionHeader(name, PRESET_EXTENSION)); + res.setBody(content); + } + else if (segment == "downloadBank") + { + std::string name; + std::string content; + GetBank(request_uri, &name, &content); + + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + res.setContentLength(content.length()); + res.set(HttpField::content_disposition, GetContentDispositionHeader(name, BANK_EXTENSION)); + res.setBody(content); + } + else + { + throw PiPedalException("Not found"); + } + } + catch (const std::exception &e) + { + if (strcmp(e.what(), "Not found") == 0) + { + ec = boost::system::errc::make_error_code(boost::system::errc::no_such_file_or_directory); + } + else + { + ec = boost::system::errc::make_error_code(boost::system::errc::invalid_argument); + } + } + } + static std::string GetFirstFolderOrFile(const std::vector&fileNames) + { + for (const auto &fileName: fileNames) + { + size_t nPos = fileName.find('/'); + if (nPos != std::string::npos) { + return fileName.substr(0,nPos); + } + } + if (fileNames.size() == 0) + { + return 0; + } else { + return fileNames[0]; + } + } + virtual void post_response( + const uri &request_uri, + HttpRequest &req, + HttpResponse &res, + std::error_code &ec) override + { + try + { + std::string segment = request_uri.segment(1); + + if (segment == "uploadPluginPresets") + { + json_reader reader(req.get_body_input_stream()); + PluginPresets presets; + reader.read(&presets); + model->UploadPluginPresets(presets); + + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + std::stringstream sResult; + sResult << -1; + std::string result = sResult.str(); + res.setContentLength(result.length()); + + res.setBody(result); + } + else if (segment == "uploadPreset") + { + json_reader reader(req.get_body_input_stream()); + + uint64_t uploadAfter = -1; + std::string strUploadAfter = request_uri.query("uploadAfter"); + if (strUploadAfter.length() != 0) + { + uploadAfter = std::stol(strUploadAfter); + } + + BankFile bankFile; + reader.read(&bankFile); + + uint64_t instanceId = model->UploadPreset(bankFile, uploadAfter); + + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + std::stringstream sResult; + sResult << instanceId; + std::string result = sResult.str(); + res.setContentLength(result.length()); + + res.setBody(result); + } + else if (segment == "uploadBank") + { + json_reader reader(req.get_body_input_stream()); + + uint64_t uploadAfter = -1; + std::string strUploadAfter = request_uri.query("uploadAfter"); + if (strUploadAfter.length() != 0) + { + uploadAfter = std::stol(strUploadAfter); + } + + BankFile bankFile; + reader.read(&bankFile); + + uint64_t instanceId = model->UploadBank(bankFile, uploadAfter); + + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + std::stringstream sResult; + sResult << instanceId; + std::string result = sResult.str(); + res.setContentLength(result.length()); + + res.setBody(result); + } else if (segment == "uploadUserFile") + { + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + const std::string instanceId = request_uri.query("id"); + const std::string directory = request_uri.query("directory"); + const std::string filename = request_uri.query("filename"); + const std::string patchProperty = request_uri.query("property"); + + + if (patchProperty.length() == 0 && directory.length() == 0) + { + // yyy no throwing! + throw PiPedalException("Malformed request."); + + } + + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + + std::string outputFileName = std::filesystem::path(directory) / filename; + + if (filename.ends_with(".zip")) + { + ExtensionChecker extensionChecker { request_uri.query("ext") }; + namespace fs = std::filesystem; + try { + auto zipFile = ZipFile::Create(req.get_body_temporary_file()); + std::vector files = zipFile->GetFiles(); + for (const auto&inputFile : files) + { + if (!inputFile.ends_with("/")) // don't process directory entries. + { + fs::path inputPath { inputFile}; + std::string extension = inputPath.extension(); + if (extensionChecker.IsValidExtension(extension)) + { + auto si = zipFile->GetFileInputStream(inputFile); + std::string path = this->model->UploadUserFile(directory,patchProperty,inputFile, si,zipFile->GetFileSize(inputFile)); + } + } + } + // set outputPath to the file or folder we would like focus to go to. + // almost always a single folder in the root. + std::string returnPath = GetFirstFolderOrFile(files); + outputFileName = this->model->GetPluginUploadDirectory() / directory / returnPath; + } catch (const std::exception &e) + { + Lv2Log::error(SS("Unzip failed. " << e.what())); + throw; + } + + } else { + outputFileName = this->model->UploadUserFile(directory,patchProperty,filename,req.get_body_input_stream(), req.content_length()); + } + + std::stringstream ss; + json_writer writer(ss); + writer.write(outputFileName); + std::string response = ss.str(); + + res.setContentLength(response.length()); + res.setBody(response); + } + else + { + throw PiPedalException("Not found"); + } + } + catch (const std::exception &e) + { + if (strcmp(e.what(), "Not found") == 0) + { + ec = boost::system::errc::make_error_code(boost::system::errc::no_such_file_or_directory); + } + else + { + ec = boost::system::errc::make_error_code(boost::system::errc::invalid_argument); + } + } + } +}; + +/* When hosting a react app, replace /var/config.json with + data that will connect the react app with our socket server. +*/ + +class InterceptConfig : public RequestHandler +{ +private: + uint64_t maxUploadSize; + int portNumber; + +public: + InterceptConfig(int portNumber, uint64_t maxUploadSize) + : RequestHandler("/var/config.json"), + maxUploadSize(maxUploadSize), + portNumber(portNumber) + { + } + std::string GetConfig(const std::string &fromAddress) + { + #define LINK_LOCAL_WEB_SOCKET 1 + #if LINK_LOCAL_WEB_SOCKET + std::string webSocketAddress = GetLinkLocalAddress(fromAddress); + Lv2Log::info(SS("Web Socket Address: " << webSocketAddress << ":" << portNumber)); + #else + std::string webSocketAddress = "*"; + #endif + + std::stringstream s; + + s << "{ \"socket_server_port\": " << portNumber + << ", \"socket_server_address\": \"" << webSocketAddress << + "\", \"ui_plugins\": [ ], \"max_upload_size\": " << maxUploadSize << " }"; + + return s.str(); + } + virtual ~InterceptConfig() {} + + virtual void head_response( + const uri &request_uri, + HttpRequest &req, + HttpResponse &res, + std::error_code &ec) override + { + // intercepted. See the other overload. + } + + virtual void head_response( + const std::string &fromAddress, + const uri &request_uri, + HttpRequest &req, + HttpResponse &res, + std::error_code &ec) override + { + std::string response = GetConfig(fromAddress); + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + res.setContentLength(response.length()); + return; + } + + virtual void get_response( + const uri &request_uri, + HttpRequest &req, + HttpResponse &res, + std::error_code &ec) override + { + // intercepted. see the other overload. + } + + virtual void get_response( + const std::string &fromAddress, + const uri &request_uri, + HttpRequest &req, + HttpResponse &res, + std::error_code &ec) override + { + std::string response = GetConfig(fromAddress); + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + res.setContentLength(response.length()); + res.setBody(response); + } +}; + +void pipedal::ConfigureWebServer( + WebServer&server, + PiPedalModel&model, + int port, + size_t maxUploadSize) +{ + std::shared_ptr interceptConfig{new InterceptConfig(port, maxUploadSize)}; + server.AddRequestHandler(interceptConfig); + + std::shared_ptr downloadIntercept = std::make_shared(&model); + server.AddRequestHandler(downloadIntercept); + + +} diff --git a/src/WebServerConfig.hpp b/src/WebServerConfig.hpp new file mode 100644 index 0000000..75b20d8 --- /dev/null +++ b/src/WebServerConfig.hpp @@ -0,0 +1,27 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#pragma once +#include + +namespace pipedal { + class WebServer; + class PiPedalModel; + void ConfigureWebServer(WebServer&server,PiPedalModel&model,int port,size_t maxUploadSize); +} \ No newline at end of file diff --git a/src/WebServerTest.cpp b/src/WebServerTest.cpp index eb0dd00..d45e0aa 100644 --- a/src/WebServerTest.cpp +++ b/src/WebServerTest.cpp @@ -35,7 +35,7 @@ TEST_CASE("WebServer shutdown", "[webServerShutdown][Build][Dev]") auto const threads = 3; auto server = WebServer::create( - address, port, doc_root.c_str(), threads); + address, port, doc_root.c_str(), threads,512*1024*1024); server->RunInBackground(); sleep(5); server->ShutDown(1000); diff --git a/src/ZipFile.cpp b/src/ZipFile.cpp new file mode 100644 index 0000000..6b221f9 --- /dev/null +++ b/src/ZipFile.cpp @@ -0,0 +1,217 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#include "ZipFile.hpp" +#include "zip.h" +#include +#include +#include "ss.hpp" +#include "Finally.hpp" +#include + +using namespace pipedal; + +ZipFile::ZipFile() +{ +} +ZipFile::~ZipFile() +{ +} + +class ZipFileImpl : public ZipFile +{ +public: + ZipFileImpl(const std::filesystem::path &path) + : path(path) + { + int errorOp = 0; + zipFile = zip_open(path.c_str(), ZIP_RDONLY, &errorOp); + if (zipFile == nullptr) + { + throw std::runtime_error("Can't open zip file."); + } + } + virtual ~ZipFileImpl(); + virtual std::vector GetFiles() override; + virtual void ExtractTo(const std::string &zipName, const std::filesystem::path &path) override; + virtual zip_file_input_stream GetFileInputStream(const std::string& filename,size_t bufferSize = 16*1024) override; + virtual size_t GetFileSize(const std::string&filename) override; + +private: + std::map nameMap; // avoid o(2) extraction operations. + const std::filesystem::path path; + zip_t *zipFile = nullptr; +}; + +ZipFile::ptr ZipFile::Create(const std::filesystem::path &path) +{ + return std::shared_ptr(new ZipFileImpl(path)); +} +ZipFileImpl::~ZipFileImpl() +{ + if (zipFile) + { + zip_close(zipFile); + zipFile = nullptr; + } +} + +std::vector ZipFileImpl::GetFiles() +{ + std::vector result; + zip_int64_t nEntries = zip_get_num_entries(zipFile, 0); + for (zip_int64_t i = 0; i < nEntries; ++i) + { + const char *name = zip_get_name(zipFile, i, ZIP_FL_ENC_STRICT); + if (name) + { + result.push_back(name); + nameMap[name] = i; + } + } + return result; +} + +void ZipFileImpl::ExtractTo(const std::string &zipName, const std::filesystem::path &path) +{ + auto fi = nameMap.find(zipName); + + if (fi == nameMap.end()) + { + // must call GetFiles() firest. + throw std::runtime_error("Zip content file not found."); + } + zip_int64_t fileIndex = fi->second; + + zip_file_t *fIn = zip_fopen_index(this->zipFile, fileIndex, 0); + if (fIn == nullptr) + { + zip_error_t *error = zip_get_error(this->zipFile); + const char *strError = zip_error_strerror(error); + + throw std::runtime_error(SS("Failed to read zip content file. " << strError)); + } + Finally t{[fIn]() mutable + { + zip_fclose(fIn); + }}; + + std::ofstream fo{path, std::ios_base::out | std::ios_base::trunc | std::ios_base::binary}; + + constexpr int BUFFER_SIZE = 64 * 1024; + std::vector vBuff(BUFFER_SIZE); + char *pBuff = (char *)&(vBuff[0]); + while (true) + { + zip_int64_t nRead = zip_fread(fIn, pBuff, BUFFER_SIZE); + if (nRead = 0) + break; + if (nRead == -1) + { + zip_error_t *error = zip_file_get_error(fIn); + const char *strError = zip_error_strerror(error); + throw std::runtime_error(SS("Error reading zip content file." << strError)); + } + fo.write(pBuff, (std::streamsize)nRead); + if (!fo) + { + throw std::runtime_error(SS("Unable to write to " << path)); + } + } +} + +zip_file_input_stream_buf::zip_file_input_stream_buf(zip_file_t *file, size_t buffer_size) + : file(file), buffer(buffer_size + putback_size) +{ + char *end = buffer.data() + buffer.size(); + setg(end, end, end); +} +zip_file_input_stream_buf::~zip_file_input_stream_buf() +{ + zip_fclose(file); +} + +std::streamsize zip_file_input_stream_buf::xsgetn(char *s, std::streamsize n) +{ + std::streamsize num_copied = 0; + while (n > 0) + { + if (gptr() >= egptr()) + { + if (underflow() == traits_type::eof()) + { + break; + } + } + std::streamsize chunk = std::min(n, egptr() - gptr()); + std::memcpy(s, gptr(), chunk); + s += chunk; + n -= chunk; + num_copied += chunk; + gbump(chunk); + } + return num_copied; +} +zip_file_input_stream_buf::int_type zip_file_input_stream_buf::underflow() +{ + if (gptr() < egptr()) + { + return traits_type::to_int_type(*gptr()); + } + + char *base = buffer.data(); + char *start = base; + + if (eback() != base) + { + std::memmove(base, egptr() - putback_size, putback_size); + start += putback_size; + } + + zip_int64_t n = zip_fread(file, start, (zip_uint64_t)(buffer.size() - (start - base))); + if (n <= 0) + { + return traits_type::eof(); + } + + setg(base, start, start + n); + return traits_type::to_int_type(*gptr()); +} + +zip_file_input_stream ZipFileImpl::GetFileInputStream(const std::string& filename, size_t bufferSize) +{ + zip_file_t *f = zip_fopen(zipFile,filename.c_str(),0); + if (!f) { + throw std::runtime_error(SS("Failed to open zip content file " << filename)); + } + return zip_file_input_stream(f,bufferSize); +} +size_t ZipFileImpl::GetFileSize(const std::string&filename) +{ + zip_stat_t stat; + if (zip_stat(zipFile,filename.c_str(),0,&stat) < 0) + { + throw std::runtime_error("File not found."); + } + if ((stat.valid & ZIP_STAT_SIZE) == 0) + { + throw std::runtime_error("Failed to get file size."); + } + return stat.size; +} diff --git a/src/ZipFile.hpp b/src/ZipFile.hpp new file mode 100644 index 0000000..61390fe --- /dev/null +++ b/src/ZipFile.hpp @@ -0,0 +1,71 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#pragma once +#include +#include +#include +#include +#include +#include + + +namespace pipedal { + + class zip_file_input_stream_buf : public std::streambuf { + private: + zip_file_t* file; + std::vector buffer; + static const size_t putback_size = 8; + public: + zip_file_input_stream_buf(zip_file_t* file, size_t buffer_size = 16384); + ~zip_file_input_stream_buf(); + protected: + virtual int_type underflow() override; + virtual std::streamsize xsgetn(char* s, std::streamsize n) override; + }; + class zip_file_input_stream : public std::istream { + private: + zip_file_input_stream_buf buf; + public: + zip_file_input_stream(zip_file_t *file, size_t buff_size = 16384) + : std::istream(nullptr), buf(file,buff_size) + { + rdbuf(&buf); + + } + }; + class ZipFile { + protected: + ZipFile(); + + public: + using ptr = std::shared_ptr; + static ptr Create(const std::filesystem::path &path); + ZipFile(const ZipFile&) = delete; + ZipFile&operator=(const ZipFile&) = delete; + virtual ~ZipFile(); + + virtual std::vector GetFiles() = 0; + virtual void ExtractTo(const std::string &zipName, const std::filesystem::path& path) = 0; + virtual zip_file_input_stream GetFileInputStream(const std::string& filename,size_t bufferSize = 16*1024) = 0; + virtual size_t GetFileSize(const std::string&filename) = 0; + + }; +} \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 9b865fa..8355963 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -26,6 +26,7 @@ #include "Lv2Log.hpp" #include "ServiceConfiguration.hpp" #include "AvahiService.hpp" +#include "WebServerConfig.hpp" #include "PiPedalSocket.hpp" #include "PluginHost.hpp" @@ -38,7 +39,6 @@ #include #include #include "HtmlHelper.hpp" -#include "Ipv6Helpers.hpp" #include #include @@ -47,11 +47,10 @@ #include + using namespace pipedal; -#define PRESET_EXTENSION ".piPreset" -#define BANK_EXTENSION ".piBank" -#define PLUGIN_PRESETS_EXTENSION ".piPluginPresets" + #ifdef __ARM_ARCH_ISA_A64 @@ -94,450 +93,6 @@ void throwSystemError(int error) { } -using namespace boost::system; - -class DownloadIntercept : public RequestHandler -{ - PiPedalModel *model; - -public: - DownloadIntercept(PiPedalModel *model) - : RequestHandler("/var"), - model(model) - { - } - - virtual bool wants(const std::string &method, const uri &request_uri) const - { - if (request_uri.segment_count() != 2 || request_uri.segment(0) != "var") - { - return false; - } - std::string segment = request_uri.segment(1); - if (segment == "uploadPluginPresets") - { - return true; - } - if (segment == "downloadPluginPresets") - { - return true; - } - if (segment == "downloadPreset") - { - std::string strInstanceId = request_uri.query("id"); - if (strInstanceId != "") - return true; - } - else if (segment == "uploadPreset") - { - return true; - } - if (segment == "downloadBank") - { - std::string strInstanceId = request_uri.query("id"); - if (strInstanceId != "") - return true; - } - else if (segment == "uploadBank") - { - return true; - } else if (segment == "uploadUserFile") - { - return true; - } - return false; - } - - std::string GetContentDispositionHeader(const std::string &name, const std::string &extension) - { - std::string fileName = name.substr(0, 64) + extension; - std::stringstream s; - - s << "attachment; filename*=" << HtmlHelper::Rfc5987EncodeFileName(fileName) << "; filename=\"" << HtmlHelper::SafeFileName(fileName) << "\""; - std::string result = s.str(); - return result; - } - - void GetPluginPresets(const uri &request_uri, std::string *pName, std::string *pContent) - { - std::string pluginUri = request_uri.query("id"); - auto plugin = model->GetLv2Host().GetPluginInfo(pluginUri); - *pName = plugin->name(); - - PluginPresets pluginPresets = model->GetPluginPresets(pluginUri); - - std::stringstream s; - json_writer writer(s,false); - writer.write(pluginPresets); - *pContent = s.str(); - } - void GetPreset(const uri &request_uri, std::string *pName, std::string *pContent) - { - std::string strInstanceId = request_uri.query("id"); - int64_t instanceId = std::stol(strInstanceId); - auto pedalboard = model->GetPreset(instanceId); - - // a certain elegance to using same file format for banks and presets. - BankFile file; - file.name(pedalboard.name()); - int64_t newInstanceId = file.addPreset(pedalboard); - file.selectedPreset(newInstanceId); - - std::stringstream s; - json_writer writer(s,false); - writer.write(file); - *pContent = s.str(); - *pName = pedalboard.name(); - } - void GetBank(const uri &request_uri, std::string *pName, std::string *pContent) - { - std::string strInstanceId = request_uri.query("id"); - int64_t instanceId = std::stol(strInstanceId); - BankFile bank; - model->GetBank(instanceId, &bank); - - std::stringstream s; - json_writer writer(s, true); // do what we can to reduce the file size. - writer.write(bank); - *pContent = s.str(); - *pName = bank.name(); - } - - virtual void head_response( - const uri &request_uri, - const HttpRequest &req, - HttpResponse &res, - std::error_code &ec) - { - try - { - std::string segment = request_uri.segment(1); - if (segment == "downloadPluginPresets") - { - std::string name; - std::string content; - GetPluginPresets(request_uri, &name, &content); - res.set(HttpField::content_type, "application/octet-stream"); - res.set(HttpField::cache_control, "no-cache"); - res.setContentLength(content.length()); - res.set(HttpField::content_disposition, GetContentDispositionHeader(name, PLUGIN_PRESETS_EXTENSION)); - return; - } - if (segment == "downloadPreset") - { - std::string name; - std::string content; - GetPreset(request_uri, &name, &content); - - res.set(HttpField::content_type, "application/octet-stream"); - res.set(HttpField::cache_control, "no-cache"); - res.setContentLength(content.length()); - res.set(HttpField::content_disposition, GetContentDispositionHeader(name, PRESET_EXTENSION)); - return; - } - if (segment == "downloadBank") - { - std::string name; - std::string content; - GetBank(request_uri, &name, &content); - - res.set(HttpField::content_type, "application/octet-stream"); - res.set(HttpField::cache_control, "no-cache"); - res.set(HttpField::content_disposition, GetContentDispositionHeader(name, BANK_EXTENSION)); - res.setContentLength(content.length()); - return; - } - throw PiPedalException("Not found."); - } - catch (const std::exception &e) - { - if (strcmp(e.what(), "Not found") == 0) - { - ec = boost::system::errc::make_error_code(boost::system::errc::no_such_file_or_directory); - } - else - { - ec = boost::system::errc::make_error_code(boost::system::errc::invalid_argument); - } - } - } - - virtual void get_response( - const uri &request_uri, - const HttpRequest &req, - HttpResponse &res, - std::error_code &ec) - { - try - { - std::string segment = request_uri.segment(1); - - if (segment == "downloadPluginPresets") - { - std::string name; - std::string content; - GetPluginPresets(request_uri, &name, &content); - res.set(HttpField::content_type, "application/octet-stream"); - res.set(HttpField::cache_control, "no-cache"); - res.setContentLength(content.length()); - res.set(HttpField::content_disposition, GetContentDispositionHeader(name, PLUGIN_PRESETS_EXTENSION)); - res.setBody(content); - } - else if (segment == "downloadPreset") - { - std::string name; - std::string content; - GetPreset(request_uri, &name, &content); - - res.set(HttpField::content_type, "application/octet-stream"); - res.set(HttpField::cache_control, "no-cache"); - res.setContentLength(content.length()); - res.set(HttpField::content_disposition, GetContentDispositionHeader(name, PRESET_EXTENSION)); - res.setBody(content); - } - else if (segment == "downloadBank") - { - std::string name; - std::string content; - GetBank(request_uri, &name, &content); - - res.set(HttpField::content_type, "application/octet-stream"); - res.set(HttpField::cache_control, "no-cache"); - res.setContentLength(content.length()); - res.set(HttpField::content_disposition, GetContentDispositionHeader(name, BANK_EXTENSION)); - res.setBody(content); - } - else - { - throw PiPedalException("Not found"); - } - } - catch (const std::exception &e) - { - if (strcmp(e.what(), "Not found") == 0) - { - ec = boost::system::errc::make_error_code(boost::system::errc::no_such_file_or_directory); - } - else - { - ec = boost::system::errc::make_error_code(boost::system::errc::invalid_argument); - } - } - } - virtual void post_response( - const uri &request_uri, - const HttpRequest &req, - HttpResponse &res, - std::error_code &ec) - { - try - { - std::string segment = request_uri.segment(1); - - if (segment == "uploadPluginPresets") - { - const std::string &presetBody = req.body(); - std::stringstream s(presetBody); - json_reader reader(s); - PluginPresets presets; - reader.read(&presets); - model->UploadPluginPresets(presets); - - res.set(HttpField::content_type, "application/json"); - res.set(HttpField::cache_control, "no-cache"); - std::stringstream sResult; - sResult << -1; - std::string result = sResult.str(); - res.setContentLength(result.length()); - - res.setBody(result); - } - else if (segment == "uploadPreset") - { - const std::string &presetBody = req.body(); - std::stringstream s(presetBody); - json_reader reader(s); - - uint64_t uploadAfter = -1; - std::string strUploadAfter = request_uri.query("uploadAfter"); - if (strUploadAfter.length() != 0) - { - uploadAfter = std::stol(strUploadAfter); - } - - BankFile bankFile; - reader.read(&bankFile); - - uint64_t instanceId = model->UploadPreset(bankFile, uploadAfter); - - res.set(HttpField::content_type, "application/json"); - res.set(HttpField::cache_control, "no-cache"); - std::stringstream sResult; - sResult << instanceId; - std::string result = sResult.str(); - res.setContentLength(result.length()); - - res.setBody(result); - } - else if (segment == "uploadBank") - { - const std::string &presetBody = req.body(); - std::istringstream s(presetBody); - json_reader reader(s); - - uint64_t uploadAfter = -1; - std::string strUploadAfter = request_uri.query("uploadAfter"); - if (strUploadAfter.length() != 0) - { - uploadAfter = std::stol(strUploadAfter); - } - - BankFile bankFile; - reader.read(&bankFile); - - uint64_t instanceId = model->UploadBank(bankFile, uploadAfter); - - res.set(HttpField::content_type, "application/json"); - res.set(HttpField::cache_control, "no-cache"); - std::stringstream sResult; - sResult << instanceId; - std::string result = sResult.str(); - res.setContentLength(result.length()); - - res.setBody(result); - } else if (segment == "uploadUserFile") - { - res.set(HttpField::content_type, "application/json"); - res.set(HttpField::cache_control, "no-cache"); - - const std::string &fileBody = req.body(); - const std::string &directory = request_uri.query("directory"); - const std::string &filename = request_uri.query("filename"); - const std::string &patchProperty = request_uri.query("property"); - - - if (patchProperty.length() == 0 && directory.length() == 0) - { - throw PiPedalException("Malformed request."); - - } - - res.set(HttpField::content_type, "application/json"); - res.set(HttpField::cache_control, "no-cache"); - - std::string outputFileName = std::filesystem::path(directory) / filename; - - std::string path = this->model->UploadUserFile(directory,patchProperty,filename,fileBody); - - std::stringstream ss; - json_writer writer(ss); - writer.write(outputFileName); - std::string response = ss.str(); - - res.setContentLength(response.length()); - res.setBody(response); - } - else - { - throw PiPedalException("Not found"); - } - } - catch (const std::exception &e) - { - if (strcmp(e.what(), "Not found") == 0) - { - ec = boost::system::errc::make_error_code(boost::system::errc::no_such_file_or_directory); - } - else - { - ec = boost::system::errc::make_error_code(boost::system::errc::invalid_argument); - } - } - } -}; - -/* When hosting a react app, replace /var/config.json with - data that will connect the react app with our socket server. -*/ - -class InterceptConfig : public RequestHandler -{ -private: - uint64_t maxUploadSize; - int portNumber; - -public: - InterceptConfig(int portNumber, uint64_t maxUploadSize) - : RequestHandler("/var/config.json"), - maxUploadSize(maxUploadSize), - portNumber(portNumber) - { - } - std::string GetConfig(const std::string &fromAddress) - { - #define LINK_LOCAL_WEB_SOCKET 1 - #if LINK_LOCAL_WEB_SOCKET - std::string webSocketAddress = GetLinkLocalAddress(fromAddress); - Lv2Log::info(SS("Web Socket Address: " << webSocketAddress << ":" << portNumber)); - #else - std::string webSocketAddress = "*"; - #endif - - std::stringstream s; - - s << "{ \"socket_server_port\": " << portNumber - << ", \"socket_server_address\": \"" << webSocketAddress << "\", \"ui_plugins\": [ ], \"max_upload_size\": " << maxUploadSize << " }"; - - return s.str(); - } - virtual ~InterceptConfig() {} - - virtual void head_response( - const uri &request_uri, - const HttpRequest &req, - HttpResponse &res, - std::error_code &ec) - { - // intercepted. See the other overload. - } - - virtual void head_response( - const std::string &fromAddress, - const uri &request_uri, - const HttpRequest &req, - HttpResponse &res, - std::error_code &ec) - { - std::string response = GetConfig(fromAddress); - res.set(HttpField::content_type, "application/json"); - res.set(HttpField::cache_control, "no-cache"); - res.setContentLength(response.length()); - return; - } - - virtual void get_response( - const uri &request_uri, - const HttpRequest &req, - HttpResponse &res, - std::error_code &ec) - { - // intercepted. see the other overload. - } - - virtual void get_response( - const std::string &fromAddress, - const uri &request_uri, - const HttpRequest &req, - HttpResponse &res, - std::error_code &ec) - { - std::string response = GetConfig(fromAddress); - res.set(HttpField::content_type, "application/json"); - res.set(HttpField::cache_control, "no-cache"); - res.setContentLength(response.length()); - res.setBody(response); - } -}; static bool isJackServiceRunning() { @@ -659,7 +214,7 @@ int main(int argc, char *argv[]) auto const threads = std::max(1, configuration.GetThreads()); server = WebServer::create( - address, port, web_root.c_str(), threads); + address, port, web_root.c_str(), threads,configuration.GetMaxUploadSize()); Lv2Log::info("Document root: %s Threads: %d", doc_root.c_str(), (int)threads); @@ -794,12 +349,7 @@ int main(int argc, char *argv[]) server->AddSocketFactory(pipedalSocketFactory); - std::shared_ptr interceptConfig{new InterceptConfig(port, configuration.GetMaxUploadSize())}; - server->AddRequestHandler(interceptConfig); - - std::shared_ptr downloadIntercept = std::make_shared(&model); - server->AddRequestHandler(downloadIntercept); - + ConfigureWebServer(*server,model,port,configuration.GetMaxUploadSize()); { server->RunInBackground(-1); diff --git a/todo.txt b/todo.txt index 2e0b1e4..a0d6a32 100644 --- a/todo.txt +++ b/todo.txt @@ -1,8 +1,8 @@ -- make sure lv2-dev shows up in packaging. Make sure dhcpcd shows up in packaging -- Toob nam input VU is not working. -- x TooB NAM: dbVu telltale is zero0. -- x TooB NAM: bass [] mid trebble. -- X check FLAC decode. +- Zip file upload in a subdirectory. +- Turning off wifi-direct sould re-enable NetworkManager. +- ToobML save/restore state. + + - make app use the same theme settings as the website. - BUG: gcs when we have an animated output control - Shutdown/reboot midi bindings @@ -10,12 +10,12 @@ - do we want to take a second crack at establishin multi-p2p connections with the driver_param? -sudo apt install libsdbus-c++-dev libnm-dev - Pri Description ----------------- +9 Upload proteus patches. +8 Migrate to Vite toolchain. 5 Re-use plugin instances when rebuilding pedalboard.