Merge pull request #169 from rerdavies/dev

v1.2.37
This commit is contained in:
Robin Davies
2024-08-18 14:02:04 -04:00
committed by GitHub
64 changed files with 3530 additions and 868 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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/")
+2 -2
View File
@@ -1,12 +1,12 @@
<img src='docs/GithubBanner.png' width="100%" /><br/>
Download: <a href='https://rerdavies.github.io/pipedal/download.html'>v1.2.36 Beta3</a>
Download: <a href='https://rerdavies.github.io/pipedal/download.html'>v1.2.36 Beta4</a>
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.
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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.
+22
View File
@@ -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:
+3 -3
View File
@@ -4,17 +4,17 @@
Download the most recent Debian (.deb) package for your platform:
- <a href="https://github.com/rerdavies/pipedal/releases/download/v1.2.36/pipedal_1.2.36_arm64.deb">Raspberry Pi OS Bookworm (64-bit) v1.2.36 Beta3</a>
- <a href="https://github.com/rerdavies/pipedal/releases/download/v1.2.37/pipedal_1.2.37_arm64.deb">Raspberry Pi OS Bookworm (64-bit) v1.2.37 Beta4</a>
- <a href="https://github.com/rerdavies/pipedal/releases/download/v1.1.31/pipedal_1.1.31_arm64.deb">Ubuntu 21.04 or Raspberry Pi OS bullseyeyeye (64-bit) v1.1.31</a>
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.
+2 -2
View File
@@ -1,13 +1,13 @@
<img src="GithubBanner.png" width="100%"/>
<a href="Installing.html"><i>v1.2.36</i></a>
<a href="Installing.html"><i>v1.2.37</i></a>
&nbsp;
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.
Binary file not shown.
Binary file not shown.
+60 -5
View File
@@ -26,6 +26,12 @@
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix toobml: <http://two-play.com/plugins/toob-ml#> .
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
<http://two-play.com/plugins/toob#frequencyResponseVector>
;
@@ -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
<http://two-play.com/plugins/toob-ml#piPedalUI>
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 ;
+2 -2
View File
@@ -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": []
}
+69 -27
View File
@@ -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 (
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<AppThemed/>
<AppThemed />
</ThemeProvider>
</StyledEngineProvider>
);
+1 -1
View File
@@ -879,7 +879,7 @@ render() {
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={this.handleCloseAlert} color="primary" autoFocus>
<Button variant="dialogPrimary" onClick={this.handleCloseAlert} color="primary" autoFocus>
OK
</Button>
</DialogActions>
+2 -2
View File
@@ -546,10 +546,10 @@ const BankDialog = withStyles(styles, { withTheme: true })(
<Typography>Are you sure you want to delete bank '{this.getSelectedBankName()}'?</Typography>
</DialogContent>
<DialogActions>
<Button onClick={()=> this.handleDeletePromptClose()} color="primary">
<Button variant="dialogSecondary" onClick={()=> this.handleDeletePromptClose()} color="primary">
Cancel
</Button>
<Button onClick={()=> this.handleDeletePromptOk()} color="secondary" >
<Button variant="dialogPrimary" onClick={()=> this.handleDeletePromptOk()} color="secondary" >
DELETE
</Button>
+15 -1
View File
@@ -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<FilePropertyControlProps>, prevState: Readonly<FilePropertyControlState>, snapshot?: any): void {
if (prevProps.fileProperty.patchProperty !== this.props.fileProperty.patchProperty
+515 -137
View File
@@ -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<typeof styles> {
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<FilePropertyDialogProps, FilePropertyDialogState> {
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<FilePropertyDialogProps, FilePropertyDialogState> {
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<FilePr
this.state = {
fullScreen: false,
selectedFile: props.selectedFile,
navDirectory: this.getNavDirectoryFromFile(props.selectedFile, props.fileProperty),
hasSelection: false,
canDelete: false,
columns: 0,
columnWidth: 1,
files: [],
fileEntries: [],
openUploadFileDialog: false,
openConfirmDeleteDialog: false
openConfirmDeleteDialog: false,
menuAnchorEl: null,
newFolderDialogOpen: false,
renameDialogOpen: false,
moveDialogOpen: false
};
this.requestScroll = true;
this.requestFiles();
}
private scrollRef: HTMLDivElement | null = null;
onScrollRef(element: HTMLDivElement | null) {
this.scrollRef = element;
this.maybeScrollIntoView();
}
private maybeScrollIntoView()
{
if (this.scrollRef !== null && this.state.files.length !== 0 && this.mounted && this.requestScroll)
{
private maybeScrollIntoView() {
if (this.scrollRef !== null && this.state.fileEntries.length !== 0 && this.mounted && this.requestScroll) {
this.requestScroll = false;
let options: ScrollIntoViewOptions = { block: "nearest"};
let options: ScrollIntoViewOptions = { block: "nearest" };
options.block = "nearest";
this.scrollRef.scrollIntoView(options);
@@ -102,7 +201,7 @@ export default class FilePropertyDialog extends ResizeResponsiveComponent<FilePr
private mounted: boolean = false;
private model: PiPedalModel;
private requestFiles() {
private requestFiles(navPath: string) {
if (!this.props.open) {
return;
}
@@ -110,44 +209,44 @@ export default class FilePropertyDialog extends ResizeResponsiveComponent<FilePr
return;
}
this.model.requestFileList(this.props.fileProperty)
this.model.requestFileList2(navPath, this.props.fileProperty)
.then((files) => {
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<FilePr
}
componentDidUpdate(prevProps: Readonly<FilePropertyDialogProps>, prevState: Readonly<FilePropertyDialogState>, 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<FilePr
onSelectValue(selectedFile: string) {
this.requestScroll = true;
this.setState({ selectedFile: selectedFile, hasSelection: this.isFileInList(this.state.files, selectedFile) })
this.setState({ selectedFile: selectedFile, hasSelection: this.isFileInList(this.state.fileEntries, selectedFile) })
}
onDoubleClickValue(selectedFile: string) {
this.props.onOk(this.props.fileProperty,selectedFile);
this.openSelectedFile();
}
handleDelete() {
this.setState({openConfirmDeleteDialog: true});
handleMenuOpen(event: React.MouseEvent<HTMLElement>) {
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 (<div style={{ height: 0 }} />);
}
let breadcrumbs: React.ReactElement[] = [(
<Link underline="hover"
color="inherit"
key="1" onClick={() => { this.navigate(""); }}
sx={{ display: 'flex', alignItems: 'center' }}
>
<HomeIcon sx={{ mr: 0.6 }} fontSize="inherit" />
Home
</Link>
)
];
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((
<Link underline="hover" key={(i + 1).toString()}
color="inherit" onClick={() => { this.navigate(myTarget) }}>
{directories[i]}
</Link>
));
}
{
let lastdirectory = directories[directories.length - 1];
breadcrumbs.push((
<Typography noWrap key={directories.length.toString()} color="text.secondary"> {lastdirectory}</Typography>
));
}
return (
<Breadcrumbs separator='/' aria-label="breadcrumb" style={{ marginLeft: 24 }}>
{breadcrumbs}
</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 &&
(
<DialogEx onClose={() => this.props.onCancel()} open={this.props.open} tag="fileProperty" fullWidth maxWidth="xl" style={{ height: "90%" }}
PaperProps={{ style: { minHeight: "90%", maxHeight: "90%", overflowY: "visible" } }}
>
<DialogTitle >
<div>
<IconButton edge="start" color="inherit" onClick={() => { this.props.onCancel(); }} aria-label="back"
<Toolbar style={{ padding: 0 }}>
<IconButton
edge="start"
color="inherit"
aria-label="back"
style={{ opacity: 0.6 }}
onClick={() => { this.props.onCancel(); }}
>
<ArrowBackIcon fontSize="small" style={{ opacity: "0.6" }} />
<ArrowBackIcon style={{ width: 24, height: 24 }} />
</IconButton>
<Typography display="inline" >{this.props.fileProperty.label}</Typography>
<Typography noWrap component="div" sx={{ flexGrow: 1 }}>
{this.props.fileProperty.label}
</Typography>
<IconButton
aria-label="new folder"
edge="end"
color="inherit"
style={{ opacity: 0.6, marginRight: 8 }}
onClick={(ev) => { this.onNewFolder(); }}
>
<CreateNewFolderIcon />
</IconButton>
<IconButton
aria-label="display more actions"
edge="end"
color="inherit"
style={{ opacity: 0.6 }}
onClick={(ev) => { this.handleMenuOpen(ev); }}
>
<MoreIcon />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={this.state.menuAnchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={this.state.menuAnchorEl !== null}
onClose={() => { this.handleMenuClose(); }}
>
<MenuItem onClick={() => { this.handleMenuClose(); this.onNewFolder(); }}>New folder</MenuItem>
{this.hasSelectedFileOrFolder() && (<Divider />)}
{this.hasSelectedFileOrFolder() && (<MenuItem onClick={() => { this.handleMenuClose();this.onMove(); }}>Move</MenuItem>)}
{this.hasSelectedFileOrFolder() && (<MenuItem onClick={() => { this.handleMenuClose();this.onRename(); }}>Rename</MenuItem>)}
</Menu>
</Toolbar>
<div style={{ flex: "0 0 auto", marginLeft: "auto", marginRight: "auto" }}>
{this.renderBreadcrumbs()}
</div>
</DialogTitle>
<div style={{ flex: "0 0 auto", height: "1px", background: "rgba(0,0,0,0.2" }}>&nbsp;</div>
<div style={{ flex: "0 0 auto", height: "1px", background: isDarkMode() ? "rgba(255,255,255,0.1" : "rgba(0,0,0,0.2" }}>&nbsp;</div>
<div style={{ flex: "1 1 auto", height: "300", display: "flex", flexFlow: "row wrap", overflowX: "hidden", overflowY: "auto" }}>
<div
ref={(element)=> 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 }}>
<div
ref={(element) => 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 = "<none>";
} 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 (
<ButtonBase key={this.mapKey++}
<ButtonBase key={value.filename}
style={{ width: columnWidth, flex: "0 0 auto", height: 48, position: "relative" }}
onClick={() => this.onSelectValue(value)} onDoubleClick={()=> {this.onDoubleClickValue(value);}}
onClick={() => this.onSelectValue(value.filename)} onDoubleClick={() => { this.onDoubleClickValue(value.filename); }}
>
<div ref={scrollRef} style={{ position: "absolute", background: selectBg, width: "100%", height: "100%", borderRadius: 4 }} />
<div style={{ display: "flex", flexFlow: "row nowrap", textOverflow: "ellipsis", justifyContent: "start", alignItems: "center", width: "100%", height: "100%" }}>
<AudioFileIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />
<Typography noWrap style={{ flex: "1 1 auto", textAlign: "left" }}>{displayValue}</Typography>
{value.filename === "" ?
(<InsertDriveFileOutlinedIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />)
: (
value.isDirectory ? (
<FolderIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />
) : (
<AudioFileIcon style={{ flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }} />
))}
<Typography noWrap className={classes.secondaryText} variant="body2" style={{ flex: "1 1 auto", textAlign: "left" }}>{displayValue}</Typography>
</div>
</ButtonBase>
);
@@ -333,25 +572,26 @@ export default class FilePropertyDialog extends ResizeResponsiveComponent<FilePr
<DialogActions style={{ justifyContent: "stretch" }}>
<div style={{ display: "flex", width: "100%", alignItems: "center", flexFlow: "row nowrap" }}>
<IconButton style={{ visibility: (this.state.hasSelection ? "visible" : "hidden") }} aria-label="delete" component="label" color="primary"
onClick={()=> this.handleDelete()} >
disabled={!this.state.hasSelection || this.state.selectedFile === ""}
onClick={() => this.handleDelete()} >
<OldDeleteIcon fontSize='small' />
</IconButton>
<Button style={{ flex: "0 0 auto" }} aria-label="upload" variant="text" startIcon={<FileUploadIcon />}
onClick={()=> { this.setState({openUploadFileDialog: true})}}
onClick={() => { this.setState({ openUploadFileDialog: true }) }}
>
<div>Upload</div>
</Button>
<div style={{ flex: "1 1 auto" }}>&nbsp;</div>
<Button onClick={() => { this.props.onCancel(); }} aria-label="cancel">
<Button variant="dialogSecondary" onClick={() => { this.props.onCancel(); }} aria-label="cancel">
Cancel
</Button>
<Button style={{ flex: "0 0 auto" }}
onClick={() => { this.props.onOk(this.props.fileProperty, this.state.selectedFile); }}
color="secondary"
<Button variant="dialogPrimary" style={{ flex: "0 0 auto" }}
onClick={() => { this.openSelectedFile(); }}
disabled={(!this.state.hasSelection) && this.state.selectedFile !== ""} aria-label="select"
>
>
Select
</Button>
</div>
@@ -364,22 +604,160 @@ export default class FilePropertyDialog extends ResizeResponsiveComponent<FilePr
this.setState({ openUploadFileDialog: false });
}
}
uploadPage={ "uploadUserFile?directory="+ encodeURIComponent(this.props.fileProperty.directory)}
onUploaded={()=> { this.requestFiles();}}
uploadPage={
"uploadUserFile?directory=" + encodeURIComponent(
pathConcat(this.props.fileProperty.directory,this.state.navDirectory)
)
+ "&ext="
+ encodeURIComponent(this.getFileExtensionList(this.props.fileProperty))
}
onUploaded={() => { this.requestFiles(this.state.navDirectory); }}
fileProperty={this.props.fileProperty}
/>
<OkCancelDialog open={this.state.openConfirmDeleteDialog}
text="Are you sure you want to delete the selected file?"
text={
(this.isDirectory(this.state.selectedFile))
? "Are you sure you want to delete the selected directory and all its contents?"
: "Are you sure you want to delete the selected file?"}
okButtonText="Delete"
onOk={()=>this.handleConfirmDelete()}
onClose={()=>{
this.setState({openConfirmDeleteDialog: false});
onOk={() => this.handleConfirmDelete()}
onClose={() => {
this.setState({ openConfirmDeleteDialog: false });
}}
/>
{
this.state.newFolderDialogOpen && (
<RenameDialog open={this.state.newFolderDialogOpen} defaultName=""
onOk={(newName) => { this.setState({ newFolderDialogOpen: false }); this.onExecuteNewFolder(newName) }}
onClose={() => { this.setState({ newFolderDialogOpen: false }); }}
acceptActionName="OK"
/>
)
}
{
this.state.renameDialogOpen && (
<RenameDialog open={this.state.renameDialogOpen} defaultName={this.renameDefaultName()}
onOk={(newName) => { this.setState({ renameDialogOpen: false }); this.onExecuteRename(newName) }}
onClose={() => { this.setState({ renameDialogOpen: false }); }}
acceptActionName="OK"
/>
)
}
{
this.state.moveDialogOpen && (
(
<FilePropertyDirectorySelectDialog
open={this.state.moveDialogOpen}
uiFileProperty={this.props.fileProperty}
defaultPath={this.getDefaultPath()}
excludeDirectory={
this.isDirectory(this.state.selectedFile)
? pathConcat(this.state.navDirectory,pathFileName(this.state.selectedFile)): ""}
onClose={() => {this.setState({moveDialogOpen: false});}}
onOk={
(path) => {
this.setState({moveDialogOpen: false});
this.setDefaultPath(path);
this.onExecuteMove(path);
}
}
/>
)
)
}
</DialogEx>
);
}
}
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());
}
);
}
});
@@ -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<FilePropertyDirectorySelectDialogProps, FilePropertyDirectorySelectDialogState> {
private model: PiPedalModel;
private refSelected: React.Ref<HTMLElement>;
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<HTMLElement>();
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<FilePropertyDirectorySelectDialogProps>, prevState: Readonly<FilePropertyDirectorySelectDialogState>, 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 (
<div key={directoryTree.path} style={{flexGrow: 1}}>
<div style={{ width: "100%", display: "flex", flexFlow: "row", flexWrap: "nowrap", justifyContent: "flex-start", alignItems: "center" }}>
<IconButton onClick={()=>{ this.onToggleExpand(directoryTree); }}
style={{opacity: showToggle ? 1: 0}}
disabled={!showToggle}
>
{
directoryTree.expanded &&
(
<ArrowDropDownIcon />
)
}
{
!directoryTree.expanded &&
(
<ArrowRightIcon/>
)
}
</IconButton>
<ButtonBase ref={ref} style={{ flexGrow: 1, flexShrink: 1, position: "relative" }} onClick={() => { this.onTreeClick(directoryTree); }}>
<div style={{ position: "absolute", background: selectBg, width: "100%", height: "100%", borderRadius: 4 }} />
<div style={{ width: "100%", display: "flex", flexFlow: "row", flexWrap: "nowrap", justifyContent: "flex-start", alignItems: "center", paddingLeft: 8 }}>
{
directoryTree.path === "" ? (
<HomeIcon fontSize="small" />
) : (
<FolderIcon />
)
}
<Typography noWrap style={{ marginLeft: 8, marginTop: 8, marginBottom: 8, textAlign: "left", flexGrow: 1, flexShrink: 1 }} variant="body2">{directoryTree.name === "" ? "Home" : directoryTree.name}</Typography>
</div>
</ButtonBase>
</div>
{directoryTree.expanded
&& directoryTree.canExpand()
&& (
<div style={{ marginLeft: 32 }}>
{
directoryTree.children.map(
(child, index) => {
return this.renderTree_(child);
}
)
}
</div>
)
}
</div>
);
}
private renderTree() {
if (!this.state.directoryTree) { return undefined; }
return this.renderTree_(this.state.directoryTree);
}
render() {
return (
<DialogEx tag="fpDirectorySelect" open={this.props.open} fullWidth onClose={() => { 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" } }}
>
<DialogTitle>
<Typography variant="body1">Select folder</Typography>
</DialogTitle>
<Divider />
<DialogContent style={{marginLeft: 0, paddingLeft: 0}} >
<div style={{ display: "flex", flexGrow: 1, flexShrink: 1, overflowY: "auto" }} >
{
this.renderTree()
}
</div>
</DialogContent>
<Divider />
<DialogActions>
<Button variant="dialogSecondary" onClick={() => { this.onClose(); }} color="primary">
Cancel
</Button>
<Button variant="dialogPrimary" onClick={() => { this.onOK(); }} color="secondary" disabled={!this.state.hasSelection} >
OK
</Button>
</DialogActions>
</DialogEx>
);
}
}
+39
View File
@@ -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[] = [];
}
+2 -2
View File
@@ -362,10 +362,10 @@ const JackServerSettingsDialog = withStyles(styles)(
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
<Button variant="dialogSecondary" onClick={handleClose} >
Cancel
</Button>
<Button onClick={() => this.handleApply()} color="primary" disabled={
<Button variant="dialogPrimary" onClick={() => this.handleApply()} disabled={
(!this.state.alsaDevices) || !this.state.jackServerSettings.valid}>
OK
</Button>
+1 -1
View File
@@ -106,7 +106,7 @@ export default class ListSelectDialog extends ResizeResponsiveComponent<ListSele
}
</List>
<DialogActions>
<Button onClick={()=> this.props.onClose()} color="primary">
<Button variant="dialogSecondary" onClick={()=> this.props.onClose()} color="primary">
Cancel
</Button>
</DialogActions>
+4 -4
View File
@@ -814,8 +814,8 @@ export const LoadPluginDialog =
</div>
</div>
<div style={{ position: "relative", float: "right", flex: "0 1 200px", width: 200, display: "flex", alignItems: "center" }}>
<Button onClick={this.handleCancel} style={{ width: 120 }} >Cancel</Button>
<Button onClick={this.handleOk} color="secondary" disabled={selectedPlugin === null} style={{ width: 180 }} >SELECT</Button>
<Button variant="dialogSecondary" onClick={this.handleCancel} style={{ width: 120 }} >Cancel</Button>
<Button variant="dialogPrimary" onClick={this.handleOk} disabled={selectedPlugin === null} style={{ width: 180 }} >SELECT</Button>
</div>
</div>
</DialogActions>
@@ -839,8 +839,8 @@ export const LoadPluginDialog =
<div className={classes.bottom} style={{height: 64}}>
<div style={{ flex: "1 1 1px" }} />
<div style={{ position: "relative", flex: "0 1 auto", display: "flex", alignItems: "center" }}>
<Button onClick={this.handleCancel} style={{ height: 48 }} >Cancel</Button>
<Button onClick={this.handleOk} color="secondary" disabled={selectedPlugin === null} style={{ height: 48 }} >SELECT</Button>
<Button variant="dialogSecondary" onClick={this.handleCancel} style={{ height: 48 }} >Cancel</Button>
<Button variant="dialogPrimary" onClick={this.handleOk} disabled={selectedPlugin === null} style={{ height: 48 }} >SELECT</Button>
</div>
</div>
</DialogActions>
+2
View File
@@ -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<Lv2Plugin> {
+2 -2
View File
@@ -70,10 +70,10 @@ export default class OkCancelDialog extends React.Component<OkCancelDialogProps,
>{text}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
<Button variant="dialogSecondary" onClick={handleClose} >
Cancel
</Button>
<Button onClick={handleOk} color="secondary" >
<Button variant="dialogPrimary" onClick={handleOk} >
{okButtonText}
</Button>
</DialogActions>
+107 -46
View File
@@ -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<boolean> {
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<void> {
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<string[]> {
return nullCast(this.webSocket)
.request<string[]>('requestFileList', piPedalFileProperty);
}
requestFileList2(relativeDirectoryPath: string,piPedalFileProperty: UiFileProperty): Promise<FileEntry[]> {
return nullCast(this.webSocket)
.request<FileEntry[]>('requestFileList2',
{relativePath: relativeDirectoryPath, fileProperty: piPedalFileProperty}
);
}
deleteUserFile(fileName: string) : Promise<boolean>
{
return nullCast(this.webSocket).request<boolean>('deleteUserFile',fileName)
return nullCast(this.webSocket).request<boolean>('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<void>((resolve, reject) => {
let ws = this.webSocket;
if (!ws) return;
if (!ws)
{
resolve();
return;
}
ws.request<void>(
"setGovernorSettings",
governor
@@ -2260,6 +2250,74 @@ export class PiPedalModel //implements PiPedalModel
});
});
}
createNewSampleDirectory(relativePath: string, uiFileProperty: UiFileProperty) : Promise<string>
{
return new Promise<string>((resolve, reject) => {
let ws = this.webSocket;
if (!ws) {
resolve("");
return;
}
ws.request<string>(
"createNewSampleDirectory",
{
relativePath: relativePath,
uiFileProperty: uiFileProperty
}
)
.then((newPath) => {
resolve(newPath);
})
.catch((err) => {
reject(err);
});
});
}
getFilePropertyDirectoryTree(uiFileProperty: UiFileProperty) : Promise<FilePropertyDirectoryTree> {
return new Promise<FilePropertyDirectoryTree>((resolve, reject) => {
let ws = this.webSocket;
if (!ws) {
resolve(new FilePropertyDirectoryTree());
return;
}
ws.request<FilePropertyDirectoryTree>(
"getFilePropertyDirectoryTree",
uiFileProperty
).then((result)=> {
resolve(new FilePropertyDirectoryTree().deserialize(result));
}).catch((e) => {
reject(e);
});
});
}
renameFilePropertyFile(oldRelativePath: string, newRelativePath: string, uiFileProperty: UiFileProperty) : Promise<string>
{
return new Promise<string>((resolve, reject) => {
let ws = this.webSocket;
if (!ws) {
resolve("");
return;
}
ws.request<string>(
"renameFilePropertyFile",
{
oldRelativePath: oldRelativePath,
newRelativePath: newRelativePath,
uiFileProperty: uiFileProperty
}
)
.then((newPath) => {
resolve(newPath);
})
.catch((err) => {
reject(err);
});
});
}
setWifiConfigSettings(wifiConfigSettings: WifiConfigSettings): Promise<void> {
let result = new Promise<void>((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;
+1 -1
View File
@@ -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 });
}
+1 -1
View File
@@ -317,7 +317,7 @@ const PluginInfoDialog = withStyles(styles)((props: PluginInfoProps) => {
</div>
</PluginInfoDialogContent>
<PluginInfoDialogActions>
<Button autoFocus onClick={handleClose} color="primary" style={{ width: "130px" }}>
<Button variant="dialogPrimary" autoFocus onClick={handleClose} style={{ width: "130px" }}>
OK
</Button>
</PluginInfoDialogActions>
+17 -2
View File
@@ -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<RenameDialog
componentDidUpdate()
{
}
checkForIllegalCharacters(filename: string) {
if (filename.indexOf('/') !== -1)
{
throw new Error("Illegal character: '/'");
}
}
render() {
let props = this.props;
@@ -86,6 +93,14 @@ export default class RenameDialog extends ResizeResponsiveComponent<RenameDialog
const handleOk = () => {
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<RenameDialog
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
<Button onClick={handleClose} variant="dialogSecondary" >
Cancel
</Button>
<Button onClick={handleOk} color="secondary" >
<Button onClick={handleOk} variant="dialogPrimary" >
{acceptActionName}
</Button>
</DialogActions>
+2 -2
View File
@@ -107,10 +107,10 @@ function SelectChannelsDialog(props: SelectChannelsDialogProps) {
)}
</List>
<DialogActions>
<Button onClick={handleClose} color="primary">
<Button onClick={handleClose} variant="dialogSecondary" >
Cancel
</Button>
<Button onClick={handleOk} color="primary" disabled={currentSelection.length === 0 || currentSelection.length > 2} >
<Button onClick={handleOk} variant="dialogPrimary" disabled={currentSelection.length === 0 || currentSelection.length > 2} >
OK
</Button>
</DialogActions>
+2 -2
View File
@@ -110,10 +110,10 @@ function SelectMidiChannelsDialog(props: SelectMidiChannelsDialogProps) {
)}
</List>
<DialogActions>
<Button onClick={handleClose} color="primary">
<Button onClick={handleClose} variant="dialogSecondary" >
Cancel
</Button>
<Button onClick={handleOk} color="primary" >
<Button onClick={handleOk} variant="dialogPrimary" >
OK
</Button>
</DialogActions>
+2 -2
View File
@@ -79,10 +79,10 @@ function SelectThemesDialog(props: SelectThemesDialogProps) {
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
<Button onClick={handleClose} variant="dialogSecondary" >
Cancel
</Button>
<Button onClick={handleOk} color="primary" >
<Button onClick={handleOk} variant="dialogPrimary" >
OK
</Button>
</DialogActions>
+1 -1
View File
@@ -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()}
/>
+9 -4
View File
@@ -208,7 +208,11 @@ export default class UploadFileDialog extends ResizeResponsiveComponent<UploadFi
{
throw new Error("Invalid file extension.");
}
let filename = await this.model.uploadFile(this.props.uploadPage, this.uploadList[i].file, "application/octet-stream", upload.abortController);
let filename = await this.model.uploadFile(
this.props.uploadPage,
this.uploadList[i].file,
"application/octet-stream",
upload.abortController);
this.props.onUploaded(filename);
upload.status = FileUploadStatus.Uploaded;
upload.statusMessage = "Uploaded.";
@@ -384,7 +388,8 @@ export default class UploadFileDialog extends ResizeResponsiveComponent<UploadFi
<DialogActions >
<Button
component="label" color="primary" style={{ whiteSpace: "nowrap", textOverflow: "ellipsis", marginLeft: 16, width: 120, flex: "0 0 auto" }}
component="label" variant="dialogPrimary" style={{ whiteSpace: "nowrap",
textOverflow: "ellipsis", marginLeft: 16, width: 120, flex: "0 0 auto" }}
>
Select&nbsp;Files
<input
@@ -399,12 +404,12 @@ export default class UploadFileDialog extends ResizeResponsiveComponent<UploadFi
{
this.state.okEnabled ?
(
<Button onClick={() => this.handleClose()} color="primary">
<Button onClick={() => this.handleClose()} variant="dialogPrimary" >
OK
</Button>
) : (
<Button onClick={() => this.handleCancel()} color="primary">
<Button onClick={() => this.handleCancel()} variant="dialogPrimary" >
Cancel
</Button>
+2 -2
View File
@@ -182,11 +182,11 @@ export default class UploadPresetDialog extends ResizeResponsiveComponent<Upload
</DialogContent>
<DialogActions>
<Button onClick={() => this.handleClose()} color="primary">
<Button onClick={() => this.handleClose()} variant="dialogSecondary" >
Cancel
</Button>
<Button
component="label" color="secondary" style={{width: 120}}
component="label" variant="dialogPrimary" style={{width: 120}}
>
Select File
<input
+4 -4
View File
@@ -337,10 +337,10 @@ const WifiConfigDialog = withStyles(styles, { withTheme: true })(
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary" style={{ width: 120 }}>
<Button onClick={handleClose} variant="dialogSecondary" style={{ width: 120 }}>
Cancel
</Button>
<Button onClick={()=> this.handleOk(false)} color="success" style={{ width: 120 }} >
<Button onClick={()=> this.handleOk(false)} variant="dialogPrimary" style={{ width: 120 }} >
OK
</Button>
</DialogActions>
@@ -356,13 +356,13 @@ const WifiConfigDialog = withStyles(styles, { withTheme: true })(
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={()=> this.setState({showWifiWarningDialog: false})} color="primary" style={{ width: 120 }}>
<Button onClick={()=> this.setState({showWifiWarningDialog: false})} variant="dialogSecondary" style={{ width: 120 }}>
Cancel
</Button>
<Button onClick={()=> {
this.setState({showWifiWarningDialog: false});
this.handleOk(true);
}} color="secondary" style={{ width: 120 }} >
}} variant="dialogPrimary" style={{ width: 120 }} >
PROCEED
</Button>
</DialogActions>
+2 -2
View File
@@ -506,10 +506,10 @@ const WifiDirectConfigDialog = withStyles(styles, { withTheme: true })(
)}
<DialogActions>
<Button onClick={handleClose} color="primary" style={{ width: 120 }}>
<Button onClick={handleClose} variant="dialogSecondary" style={{ width: 120 }}>
Cancel
</Button>
<Button onClick={() => this.handleOk()} color="primary" style={{ width: 120 }} >
<Button onClick={() => this.handleOk()} variant="dialogPrimary" style={{ width: 120 }} >
OK
</Button>
</DialogActions>
+8 -1
View File
@@ -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}
+1 -1
View File
@@ -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()`.
+28
View File
@@ -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()
+40
View File
@@ -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);
};
}
+35
View File
@@ -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()
+37
View File
@@ -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 <string>
#include <memory>
#include <vector>
namespace pipedal {
class FilePropertyDirectoryTree {
public:
using ptr = std::unique_ptr<FilePropertyDirectoryTree>;
FilePropertyDirectoryTree();
FilePropertyDirectoryTree(const std::string&directoryName);
std::string directoryName_;
std::vector<std::unique_ptr<FilePropertyDirectoryTree>> children_;
DECLARE_JSON_MAP(FilePropertyDirectoryTree);
};
}
+39
View File
@@ -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 <functional>
namespace pipedal {
class Finally {
public:
Finally(std::function<void(void)> &&fn)
:fn (std::move(fn))
{
}
~Finally() {
fn();
}
private:
std::function<void(void)> fn;
};
}
+55 -3
View File
@@ -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<std::string> PiPedalModel::GetFileList(const UiFileProperty &filePro
return std::vector<std::string>(); // don't disclose to users what the problem is.
}
}
std::vector<FileEntry> 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<FileEntry>(); // 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<std::recursive_mutex> 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<std::recursive_mutex> lock(mutex);
return storage.CreateNewSampleDirectory(relativePath, uiFileProperty);
}
FilePropertyDirectoryTree::ptr PiPedalModel::GetFilePropertydirectoryTree(const UiFileProperty&uiFileProperty)
{
std::lock_guard<std::recursive_mutex> 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();
}
+9 -2
View File
@@ -39,6 +39,7 @@
#include <thread>
#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<std::string, bool> &favorites);
std::vector<std::string> GetFileList(const UiFileProperty&fileProperty);
std::vector<FileEntry> 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();
+73
View File
@@ -40,6 +40,7 @@
#include "SysExec.hpp"
#include "PiPedalAlsa.hpp"
#include <filesystem>
#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<std::string> list = this->model.GetFileList(fileProperty);
this->Reply(replyTo,"requestFileList",list);
}
else if (message == "requestFileList2")
{
FileRequestArgs requestArgs;
pReader->read(&requestArgs);
std::vector<FileEntry> 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;
+10 -1
View File
@@ -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)
+4
View File
@@ -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<UiFileType> fileTypes_;
std::string patchProperty_;
std::string portGroup_;
std::string resourceDirectory_;
public:
using ptr = std::shared_ptr<UiFileProperty>;
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);
};
+123 -15
View File
@@ -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<const double *>(value));
} else if (type == pHost->urids->atom__Float)
}
else if (type == pHost->urids->atom__Float)
{
(*pState->values)[symbol] = *static_cast<const float *>(value);
}
else if (type == pHost->urids->atom_Int)
{
(*pState->values)[symbol] = *static_cast<const int32_t *>(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<HostWorkerThread> 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<ResourceInfo> 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());
+8 -1
View File
@@ -30,6 +30,7 @@
#include <cmath>
#include <string>
#include "IHost.hpp"
#include <set>
//#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<std::string> pluginsThatHaveBeenCheckedForResources;
void CheckForResourceInitization(const std::string pluginUri);
virtual LV2_URID_Map *GetLv2UridMap()
{
return this->mapFeature.GetMap();
+232 -35
View File
@@ -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<MidiBinding> 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<std::string> Storage::GetFileList(const UiFileProperty &fileProperty)
{
@@ -1509,7 +1498,7 @@ std::vector<std::string> 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<std::string> 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<FileEntry> Storage::GetFileList2(const std::string &relativePath,const UiFileProperty &fileProperty)
{
if (!ensureNoDotDot(relativePath))
{
ThrowPermissionDeniedError();
}
if (!UiFileProperty::IsDirectoryNameValid(fileProperty.directory()))
{
ThrowPermissionDeniedError();
}
std::vector<FileEntry> 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<uint8_t> 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<FilePropertyDirectoryTree>(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<FilePropertyDirectoryTree>("");
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;
+16 -4
View File
@@ -28,7 +28,10 @@
#include "JackServerSettings.hpp"
#include "WifiConfigSettings.hpp"
#include "WifiDirectConfigSettings.hpp"
#include "FileEntry.hpp"
#include <map>
#include <locale>
#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<std::string> GetPedalboards();
//std::vector<std::string> GetPedalboards();
const BankIndex & GetBanks() const { return bankIndex; }
@@ -149,6 +154,7 @@ public:
int64_t DeleteBank(int64_t bankId);
std::vector<std::string> GetFileList(const UiFileProperty&fileProperty);
std::vector<FileEntry> 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<MidiBinding>&bindings);
std::vector<MidiBinding> 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;
};
+61
View File
@@ -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<unsigned int>(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);
}
}
+34
View File
@@ -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 <filesystem>
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;
};
}
+557 -65
View File
@@ -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 <websocketpp/server.hpp>
#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 <boost/asio/ip/tcp.hpp>
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<type> ptr;
request_with_file_upload()
: m_buf(std::make_shared<std::string>()), 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<std::string> 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<TemporaryFile> 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<TemporaryFile>(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<std::string::size_type>(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<std::string::size_type>(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_config>
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<std::string, std::string> 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<server::connection_type::request_type &>(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 << "<!doctype html><html><head>"
<< "<title>Error 404 (Resource not found)</title><body>"
<< "<h1>Error 404</h1>"
<< "<p>The requested URL " << HtmlHelper::HtmlEncode(filename) << " was not found on this server.</p>"
<< "</body></head></html>";
<< "<title>Error 404 (Resource not found)</title><body>"
<< "<h1>Error 404</h1>"
<< "<p>The requested URL " << HtmlHelper::HtmlEncode(filename) << " was not found on this server.</p>"
<< "</body></head></html>";
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 << "<!doctype html><html><head>"
<< "<title>Error 500 (Server error)</title><body>"
<< "<h1>Error 500</h1>"
<< "<p>" << HtmlHelper::HtmlEncode(error) << "</p>"
<< "</body></head></html>";
<< "<title>Error 500 (Server error)</title><body>"
<< "<h1>Error 500</h1>"
<< "<p>" << HtmlHelper::HtmlEncode(error) << "</p>"
<< "</body></head></html>";
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<WebSocketSession>(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<ISocketFactory> WebServerImpl::GetSocketFactory(const uri &reque
}
return nullptr;
}
std::shared_ptr<WebServer> 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<WebServer>(new WebServerImpl(address.to_string(), port, rootPath, threads));
::CustomPpConfig::max_http_body_size = maxUploadSize;
}
std::shared_ptr<WebServer> pipedal::WebServer::create(
const boost::asio::ip::address &address,
int port,
const char *rootPath, int threads,
size_t maxUploadSize)
{
return std::shared_ptr<WebServer>(new WebServerImpl(address.to_string(), port, rootPath, threads, maxUploadSize));
}
+12 -8
View File
@@ -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);
};
+571
View File
@@ -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 <boost/system/error_code.hpp>
#include <filesystem>
#include "PiPedalConfiguration.hpp"
#include "PiPedalModel.hpp"
#include "Banks.hpp"
#include "Ipv6Helpers.hpp"
#include <memory>
#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<std::string> split(const std::string& s, char delimiter) {
std::vector<std::string> 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<std::string> 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<std::string>&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<std::string> 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<RequestHandler> interceptConfig{new InterceptConfig(port, maxUploadSize)};
server.AddRequestHandler(interceptConfig);
std::shared_ptr<DownloadIntercept> downloadIntercept = std::make_shared<DownloadIntercept>(&model);
server.AddRequestHandler(downloadIntercept);
}
+27
View File
@@ -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 <cstdint>
namespace pipedal {
class WebServer;
class PiPedalModel;
void ConfigureWebServer(WebServer&server,PiPedalModel&model,int port,size_t maxUploadSize);
}
+1 -1
View File
@@ -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);
+217
View File
@@ -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 <stdexcept>
#include <map>
#include "ss.hpp"
#include "Finally.hpp"
#include <cstring>
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<std::string> 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<std::string, zip_int64_t> 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<ZipFile>(new ZipFileImpl(path));
}
ZipFileImpl::~ZipFileImpl()
{
if (zipFile)
{
zip_close(zipFile);
zipFile = nullptr;
}
}
std::vector<std::string> ZipFileImpl::GetFiles()
{
std::vector<std::string> 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<char> 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;
}
+71
View File
@@ -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 <memory>
#include <string>
#include <vector>
#include <filesystem>
#include <iostream>
#include <zip.h>
namespace pipedal {
class zip_file_input_stream_buf : public std::streambuf {
private:
zip_file_t* file;
std::vector<char> 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<ZipFile>;
static ptr Create(const std::filesystem::path &path);
ZipFile(const ZipFile&) = delete;
ZipFile&operator=(const ZipFile&) = delete;
virtual ~ZipFile();
virtual std::vector<std::string> 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;
};
}
+5 -455
View File
@@ -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 <sys/stat.h>
#include <boost/asio.hpp>
#include "HtmlHelper.hpp"
#include "Ipv6Helpers.hpp"
#include <thread>
#include <atomic>
@@ -47,11 +47,10 @@
#include <systemd/sd-daemon.h>
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<int>(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<RequestHandler> interceptConfig{new InterceptConfig(port, configuration.GetMaxUploadSize())};
server->AddRequestHandler(interceptConfig);
std::shared_ptr<DownloadIntercept> downloadIntercept = std::make_shared<DownloadIntercept>(&model);
server->AddRequestHandler(downloadIntercept);
ConfigureWebServer(*server,model,port,configuration.GetMaxUploadSize());
{
server->RunInBackground(-1);
+7 -7
View File
@@ -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.